From 770a584f3f89dbfaac0aceec68cd55a444ce1396 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 01:07:33 +0500 Subject: [PATCH 1/7] Rewrite the generators for the next-gen packages v2 reads the schema through Laravel's schema builder (MySQL, MariaDB, PostgreSQL, SQLite), resolves belongsTo, hasMany and many-to-many relations (pivot tables), and builds one resource definition that every generator renders from. Generated code uses javaabu/stencil forms, javaabu/filters, javaabu/bulk-actions, javaabu/permissions policies and permissions, and Blade (javaabu/forms) or Inertia React (@javaabu/forms) views, with feature tests that run in the app. Foundations: PHP 8.4, Laravel 12/13, Testbench 10/11, Pint with the Javaabu rules, the CI matrix with Codecov, schema tests on MySQL, MariaDB and PostgreSQL, and an integration app (workbench/) that generates a whole admin and runs its tests. Closes #6 Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 13 +- .github/coverage.svg | 20 - .github/workflows/run-tests.yml | 92 - .github/workflows/tests.yml | 191 + .gitignore | 10 +- README.md | 8 +- composer.json | 128 +- config/config.php | 56 - config/generators.php | 153 + lang/dv/generators.php | 24 + lang/dv/phrases.php | 17 + lang/en/generators.php | 24 + lang/en/phrases.php | 16 + phpunit.xml.dist | 28 +- pint.json | 23 + src/Commands.php | 44 + src/Commands/Auth/BaseAuthGenerateCommand.php | 55 - ...teAuthConfirmPasswordControllerCommand.php | 14 - .../GenerateAuthControllerCommand.php | 49 - ...ateAuthForgotPasswordControllerCommand.php | 14 - .../GenerateAuthHomeControllerCommand.php | 14 - .../GenerateAuthLoginControllerCommand.php | 14 - .../GenerateAuthRegisterControllerCommand.php | 14 - ...rateAuthResetPasswordControllerCommand.php | 14 - ...ateAuthUpdatePasswordControllerCommand.php | 14 - ...erateAuthVerificationControllerCommand.php | 14 - .../Auth/GenerateAuthConfigCommand.php | 85 - .../Auth/GenerateAuthFactoryCommand.php | 44 - .../Auth/GenerateAuthModelCommand.php | 84 - .../GenerateAuthPasswordResetsCommand.php | 44 - .../Auth/GenerateAuthPermissionsCommand.php | 53 - .../Auth/GenerateAuthPolicyCommand.php | 45 - .../Auth/GenerateAuthRequestCommand.php | 45 - src/Commands/BaseGenerateCommand.php | 39 - src/Commands/GenerateAllCommand.php | 83 +- src/Commands/GenerateApiCommand.php | 16 - src/Commands/GenerateApiControllerCommand.php | 74 - src/Commands/GenerateApiTestCommand.php | 44 - src/Commands/GenerateBulkActionsCommand.php | 18 + src/Commands/GenerateCommand.php | 271 +- src/Commands/GenerateControllerCommand.php | 36 +- src/Commands/GenerateExportCommand.php | 36 +- src/Commands/GenerateFactoryCommand.php | 36 +- src/Commands/GenerateFiltersCommand.php | 18 + src/Commands/GenerateFormCommand.php | 18 + src/Commands/GenerateModelCommand.php | 70 +- src/Commands/GeneratePermissionsCommand.php | 45 +- src/Commands/GeneratePolicyCommand.php | 36 +- src/Commands/GenerateRequestCommand.php | 44 - src/Commands/GenerateRollbackCommand.php | 52 +- src/Commands/GenerateRoutesCommand.php | 49 +- src/Commands/GenerateTestCommand.php | 36 +- src/Commands/GenerateViewsCommand.php | 71 +- src/Commands/MultipleGeneratorCommand.php | 56 - src/Contracts/SchemaResolverInterface.php | 18 - .../ColumnDoesNotExistException.php | 4 +- .../MultipleTablesSuppliedException.php | 4 +- src/Exceptions/TableDoesNotExistException.php | 4 +- .../UnsupportedDbDriverException.php | 4 +- src/FieldTypes/BooleanField.php | 89 - src/FieldTypes/DateField.php | 32 - src/FieldTypes/DateTimeField.php | 32 - src/FieldTypes/DateTypeField.php | 21 - src/FieldTypes/DecimalField.php | 115 - src/FieldTypes/EnumField.php | 171 - src/FieldTypes/Field.php | 286 - src/FieldTypes/ForeignKeyField.php | 163 - src/FieldTypes/IntegerField.php | 91 - src/FieldTypes/JsonField.php | 75 - src/FieldTypes/StringField.php | 64 - src/FieldTypes/TextField.php | 55 - src/FieldTypes/TimeField.php | 32 - src/FieldTypes/YearField.php | 57 - src/Fields/BooleanField.php | 80 + src/Fields/DateField.php | 43 + src/Fields/DateTimeField.php | 65 + src/Fields/DecimalField.php | 93 + src/Fields/EnumField.php | 188 + src/Fields/Field.php | 333 ++ src/Fields/ForeignKeyField.php | 152 + src/Fields/IntegerField.php | 102 + src/Fields/JsonField.php | 91 + src/Fields/StringField.php | 112 + src/Fields/TextField.php | 76 + src/Fields/TimeField.php | 46 + src/Fields/YearField.php | 60 + src/Generators/ApiControllerGenerator.php | 113 - src/Generators/ApiTestGenerator.php | 34 - src/Generators/Auth/AuthConfigGenerator.php | 111 - src/Generators/Auth/AuthFactoryGenerator.php | 43 - src/Generators/Auth/AuthModelGenerator.php | 69 - .../Auth/AuthPasswordResetsGenerator.php | 26 - .../Auth/AuthPermissionsGenerator.php | 28 - src/Generators/Auth/AuthPolicyGenerator.php | 20 - src/Generators/Auth/AuthRequestGenerator.php | 53 - src/Generators/Auth/BaseAuthGenerator.php | 136 - ...AuthConfirmPasswordControllerGenerator.php | 20 - .../Controllers/AuthControllerGenerator.php | 57 - .../AuthForgotPasswordControllerGenerator.php | 19 - .../AuthHomeControllerGenerator.php | 29 - .../AuthLoginControllerGenerator.php | 19 - .../AuthRegisterControllerGenerator.php | 93 - .../AuthResetPasswordControllerGenerator.php | 19 - .../AuthUpdatePasswordControllerGenerator.php | 19 - .../AuthVerificationControllerGenerator.php | 19 - .../BaseAuthControllerGenerator.php | 12 - src/Generators/BaseGenerator.php | 276 - src/Generators/BladeViewsGenerator.php | 264 + src/Generators/BulkActionsGenerator.php | 86 + .../Concerns/GeneratesAuthController.php | 27 - .../Concerns/GeneratesController.php | 254 - src/Generators/Concerns/GeneratesExport.php | 78 - src/Generators/Concerns/GeneratesFactory.php | 244 - src/Generators/Concerns/GeneratesModel.php | 305 -- .../Concerns/GeneratesPermissions.php | 47 - src/Generators/Concerns/GeneratesPolicy.php | 47 - src/Generators/Concerns/GeneratesRequest.php | 231 - src/Generators/ControllerGenerator.php | 419 +- src/Generators/ExportGenerator.php | 30 +- src/Generators/FactoryGenerator.php | 47 +- src/Generators/FiltersGenerator.php | 148 + src/Generators/FormGenerator.php | 80 + src/Generators/GeneratedFile.php | 40 + src/Generators/Generator.php | 51 + src/Generators/InertiaPagesGenerator.php | 556 ++ src/Generators/ModelGenerator.php | 198 +- src/Generators/PermissionsGenerator.php | 37 +- src/Generators/PolicyGenerator.php | 36 +- src/Generators/RequestGenerator.php | 20 - src/Generators/ResourceGenerator.php | 102 + src/Generators/RoutesGenerator.php | 132 +- src/Generators/TestGenerator.php | 648 ++- src/Generators/TranslationsGenerator.php | 47 + src/Generators/ViewsGenerator.php | 386 -- src/GeneratorsServiceProvider.php | 121 +- src/IconProviders/BaseIconProvider.php | 98 - src/IconProviders/FontAwesomeProvider.php | 128 - .../MaterialDesignIconicProvider.php | 946 ---- .../resources/fontawesome-v7.txt | 4677 ----------------- src/Resolvers/BaseSchemaResolver.php | 92 - src/Resolvers/SchemaResolverMySql.php | 301 -- src/Resource/MediaCollection.php | 61 + src/Resource/ResourceDefinition.php | 554 ++ src/Resource/ResourceOptions.php | 71 + src/Resource/ResourceResolver.php | 402 ++ src/Schema/Column.php | 42 + src/Schema/ForeignKey.php | 13 + src/Schema/RelationResolver.php | 196 + src/Schema/Relations/BelongsTo.php | 39 + src/Schema/Relations/BelongsToMany.php | 65 + src/Schema/Relations/HasMany.php | 40 + src/Schema/Relations/Relation.php | 25 + src/Schema/SchemaInspector.php | 402 ++ src/Schema/Table.php | 85 + src/Support/Imports.php | 88 + src/Support/Naming.php | 27 + src/Support/PhpFile.php | 159 + src/Support/Stub.php | 121 + src/Support/TableProperties.php | 56 - src/Support/Translations.php | 29 + .../Auth/AuthModelSoftDeletesController.stub | 336 -- .../Auth/ConfirmPasswordController.stub | 20 - .../Auth/ForgotPasswordController.stub | 31 - stubs/Controllers/Auth/HomeController.stub | 21 - stubs/Controllers/Auth/LoginController.stub | 19 - .../Controllers/Auth/RegisterController.stub | 47 - .../Auth/ResetPasswordController.stub | 25 - .../Auth/UpdatePasswordController.stub | 28 - stubs/Controllers/Auth/UserController.stub | 73 - .../Auth/VerificationController.stub | 25 - .../Controllers/Auth/_registerRulesBody.stub | 13 - stubs/Controllers/ModelApiController.stub | 89 - stubs/Controllers/ModelController.stub | 168 - .../ModelSoftDeletesController.stub | 224 - stubs/Controllers/_controllerBoolean.stub | 3 - stubs/Controllers/_controllerEagerLoads.stub | 2 - stubs/Controllers/_controllerForeignKey.stub | 3 - stubs/Controllers/_controllerUpdateFill.stub | 1 - stubs/Controllers/_orderBy.stub | 1 - stubs/Exports/ModelExport.stub | 18 - stubs/Exports/_exportRelations.stub | 7 - stubs/Menus/_sidebar.stub | 4 - stubs/Models/AuthModel.stub | 105 - stubs/Models/Model.stub | 58 - stubs/Models/_adminLinkName.stub | 9 - stubs/Models/_authCasts.stub | 13 - stubs/Models/_casts.stub | 11 - stubs/Models/_dateMutator.stub | 7 - stubs/Models/_modelForeignKey.stub | 7 - stubs/Models/_nullableDateMutator.stub | 7 - stubs/Policies/ModelPolicy.stub | 60 - stubs/Policies/ModelSoftDeletesPolicy.stub | 84 - stubs/Policies/_authPolicyMethods.stub | 16 - stubs/Requests/AuthModelRequest.stub | 56 - stubs/Requests/ModelRequest.stub | 27 - stubs/Requests/_authBlankRulesBody.stub | 1 - stubs/Requests/_authRulesBody.stub | 13 - stubs/Requests/_rulesBody.stub | 13 - stubs/bulk-actions/BulkActions.stub | 24 + stubs/config/auth/_default-guard.stub | 1 - stubs/config/auth/_default-passwords.stub | 1 - stubs/config/auth/_guards.stub | 9 - stubs/config/auth/_passport-guards.stub | 1 - stubs/config/auth/_passwords.stub | 6 - stubs/config/auth/_providers.stub | 4 - stubs/controller/Controller.stub | 10 + stubs/export/Export.stub | 13 + stubs/factories/AuthFactory.stub | 31 - stubs/factories/ModelFactory.stub | 25 - stubs/factories/_factoryForeignKey.stub | 8 - .../factories/_factoryRequiredRelations.stub | 4 - stubs/factory/Factory.stub | 30 + stubs/filters/Filters.stub | 28 + stubs/form/Form.stub | 15 + ...eate_auth_password_reset_tokens_table.stub | 32 - stubs/model/Model.stub | 21 + stubs/policy/Policy.stub | 17 + stubs/resource/Resource.stub | 23 + stubs/routes/_admin.stub | 5 - stubs/routes/_adminSoftDeletes.stub | 8 - stubs/routes/_api.stub | 5 - stubs/routes/admin.stub | 20 + stubs/seeders/PermissionsSeeder.stub | 26 - stubs/seeders/_authPermissions.stub | 1 - stubs/seeders/_permissions.stub | 9 - stubs/seeders/_permissionsSoftDeletes.stub | 10 - stubs/test/ControllerTest.stub | 35 + stubs/tests/ModelApiControllerTest.stub | 78 - stubs/tests/ModelControllerTest.stub | 358 -- .../tests/ModelSoftDeletesControllerTest.stub | 485 -- stubs/views/blade/create.blade.stub | 12 + stubs/views/blade/edit.blade.stub | 18 + stubs/views/blade/index.blade.stub | 48 + stubs/views/blade/show.blade.stub | 23 + .../model-soft-deletes/_actions.blade.stub | 48 - .../model-soft-deletes/_filter.blade.stub | 30 - .../views/model-soft-deletes/_list.blade.stub | 48 - .../views/model-soft-deletes/index.blade.stub | 36 - stubs/views/model/_actions.blade.stub | 41 - stubs/views/model/_bulk.blade.stub | 9 - stubs/views/model/_details.blade.stub | 3 - stubs/views/model/_filter.blade.stub | 30 - stubs/views/model/_form.blade.stub | 13 - stubs/views/model/_list.blade.stub | 32 - stubs/views/model/_table.blade.stub | 35 - stubs/views/model/create.blade.stub | 9 - stubs/views/model/edit.blade.stub | 9 - stubs/views/model/index.blade.stub | 29 - stubs/views/model/model.blade.stub | 12 - stubs/views/model/show.blade.stub | 7 - tests/Concerns/AssertsSnapshots.php | 29 + ...thConfirmPasswordControllerCommandTest.php | 40 - .../GenerateAuthControllerCommandTest.php | 40 - ...uthForgotPasswordControllerCommandTest.php | 40 - .../GenerateAuthHomeControllerCommandTest.php | 40 - ...GenerateAuthLoginControllerCommandTest.php | 40 - ...erateAuthRegisterControllerCommandTest.php | 40 - ...AuthResetPasswordControllerCommandTest.php | 40 - ...uthUpdatePasswordControllerCommandTest.php | 40 - ...eAuthVerificationControllerCommandTest.php | 40 - .../Auth/GenerateAuthConfigCommandTest.php | 70 - .../Auth/GenerateAuthFactoryCommandTest.php | 40 - .../Auth/GenerateAuthModelCommandTest.php | 94 - .../GenerateAuthPasswordResetsCommandTest.php | 42 - .../GenerateAuthPermissionsCommandTest.php | 90 - .../Auth/GenerateAuthPolicyCommandTest.php | 40 - .../Auth/GenerateAuthRequestCommandTest.php | 40 - .../Commands/BaseGenerateCommandTest.php | 83 - .../Commands/GenerateAllCommandTest.php | 102 - .../Commands/GenerateApiCommandTest.php | 59 - .../GenerateApiControllerCommandTest.php | 80 - .../Commands/GenerateApiTestCommandTest.php | 40 - .../Feature/Commands/GenerateCommandsTest.php | 211 + .../GenerateControllerCommandTest.php | 40 - .../Commands/GenerateExportCommandTest.php | 40 - .../Commands/GenerateFactoryCommandTest.php | 40 - .../Commands/GenerateModelCommandTest.php | 93 - .../GeneratePermissionsCommandTest.php | 93 - .../Commands/GeneratePolicyCommandTest.php | 40 - .../Commands/GenerateRequestCommandTest.php | 40 - .../Commands/GenerateRoutesCommandTest.php | 55 - .../Commands/GenerateTestCommandTest.php | 40 - .../Commands/GenerateViewsCommandTest.php | 136 - tests/Feature/GeneratorSnapshotsTest.php | 92 + .../Enums/OrderStatuses.php | 2 +- .../Enums/SingleCaseOrderStatuses.php | 2 +- ...4_04_28_144110_create_categories_table.php | 1 + ...024_04_28_144111_create_products_table.php | 1 + .../2024_04_28_144112_create_orders_table.php | 5 +- ...24_04_28_144113_create_countries_table.php | 1 + .../2024_04_28_144113_create_posts_table.php | 1 + ...024_04_28_144114_create_payments_table.php | 1 + ...04_28_144115_create_certificates_table.php | 1 + ...04_28_144116_create_all_foreigns_table.php | 1 + ...24_04_28_144117_create_customers_table.php | 1 + ...04_28_144118_create_public_users_table.php | 1 + .../2024_04_28_144119_create_tags_table.php | 29 + ..._create_single_value_enum_orders_table.php | 5 +- tests/TestCase.php | 94 +- .../Providers/TestServiceProvider.php | 24 - tests/Unit/BooleanFieldTest.php | 19 - tests/Unit/DecimalFieldTest.php | 17 - tests/Unit/FieldTest.php | 37 - tests/Unit/ForeignKeyFieldTest.php | 55 - .../Generators/ApiControllerGeneratorTest.php | 42 - .../Unit/Generators/ApiTestGeneratorTest.php | 22 - .../Auth/AuthConfigGeneratorTest.php | 88 - .../Auth/AuthFactoryGeneratorTest.php | 22 - .../Auth/AuthModelGeneratorTest.php | 32 - .../Auth/AuthPasswordResetsGeneratorTest.php | 31 - .../Auth/AuthPermissionsGeneratorTest.php | 22 - .../Auth/AuthPolicyGeneratorTest.php | 22 - .../Auth/AuthRequestGeneratorTest.php | 43 - .../Generators/Auth/BaseAuthGeneratorTest.php | 195 - ...ConfirmPasswordControllerGeneratorTest.php | 22 - .../AuthControllerGeneratorTest.php | 22 - ...hForgotPasswordControllerGeneratorTest.php | 22 - .../AuthHomeControllerGeneratorTest.php | 22 - .../AuthLoginControllerGeneratorTest.php | 22 - .../AuthRegisterControllerGeneratorTest.php | 42 - ...thResetPasswordControllerGeneratorTest.php | 22 - ...hUpdatePasswordControllerGeneratorTest.php | 22 - ...uthVerificationControllerGeneratorTest.php | 22 - tests/Unit/Generators/BaseGeneratorTest.php | 174 - .../Concerns/GeneratesAuthControllerTest.php | 26 - .../Generators/ControllerGeneratorTest.php | 82 - tests/Unit/Generators/ExportGeneratorTest.php | 32 - .../Unit/Generators/FactoryGeneratorTest.php | 205 - tests/Unit/Generators/ModelGeneratorTest.php | 72 - .../Generators/PermissionsGeneratorTest.php | 32 - tests/Unit/Generators/PolicyGeneratorTest.php | 32 - .../Unit/Generators/RequestGeneratorTest.php | 172 - tests/Unit/Generators/RoutesGeneratorTest.php | 32 - tests/Unit/Generators/TestGeneratorTest.php | 142 - tests/Unit/Generators/ViewsGeneratorTest.php | 366 -- tests/Unit/IconProviderTest.php | 32 - tests/Unit/Schema/RelationResolverTest.php | 95 + tests/Unit/Schema/SchemaInspectorTest.php | 150 + tests/Unit/SchemaResolverMySqlTest.php | 341 -- tests/Unit/StringCaserTest.php | 89 - tests/Unit/StubRendererTest.php | 100 - tests/Unit/Support/ImportsTest.php | 36 + tests/Unit/Support/NamingTest.php | 61 + tests/Unit/Support/PhpFileTest.php | 78 + tests/Unit/Support/StubTest.php | 39 + tests/snapshots/categories.blade.txt | 238 + .../orders.enum-class-and-a-key-to-a-slug.txt | 382 ++ tests/snapshots/posts-with-media.blade.txt | 286 + tests/snapshots/posts-with-media.inertia.txt | 264 + .../products-nested-in-categories.blade.txt | 670 +++ .../products-nested-in-categories.inertia.txt | 768 +++ tests/snapshots/products.blade.txt | 1017 ++++ tests/snapshots/products.inertia.txt | 813 +++ .../tags.a-pivot-on-the-other-side.txt | 64 + tests/stubs/AfterAppend.php | 7 - tests/stubs/AfterAppendMarkerRemoved.php | 6 - tests/stubs/BeforeAppend.php | 6 - .../Controllers/Api/CategoriesController.php | 94 - .../Controllers/Api/ProductsController.php | 104 - .../Controllers/CategoriesController.php | 169 - .../Auth/ConfirmPasswordController.php | 20 - .../Auth/ForgotPasswordController.php | 31 - .../Customer/Auth/LoginController.php | 19 - .../Customer/Auth/RegisterController.php | 63 - .../Customer/Auth/ResetPasswordController.php | 25 - .../Auth/UpdatePasswordController.php | 28 - .../Customer/Auth/VerificationController.php | 25 - .../Customer/CustomerController.php | 80 - .../Controllers/Customer/HomeController.php | 21 - .../stubs/Controllers/CustomersController.php | 358 -- tests/stubs/Controllers/OrdersController.php | 186 - .../stubs/Controllers/ProductsController.php | 251 - .../Controllers/_categoryForeignKey.stub | 3 - tests/stubs/Controllers/_onSaleBoolean.stub | 3 - .../Controllers/_onSaleBooleanIndented.stub | 3 - tests/stubs/Controllers/_orderBy.stub | 1 - .../stubs/Controllers/_ordersEagerLoads.stub | 2 - tests/stubs/Exports/AllForeignsExport.php | 25 - tests/stubs/Exports/CategoriesExport.php | 17 - tests/stubs/Exports/ModelExport.stub | 22 - tests/stubs/Exports/ProductsExport.php | 22 - tests/stubs/Menus/AdminSidebar.php | 47 - tests/stubs/Menus/ModelsAdminSidebar.php | 57 - tests/stubs/Menus/_categoriesSidebar.stub | 4 - tests/stubs/Models/Category.php | 71 - tests/stubs/Models/Customer.php | 141 - tests/stubs/Models/Order.php | 98 - tests/stubs/Models/Product.php | 139 - tests/stubs/Models/PublicUser.php | 102 - tests/stubs/Models/_categoryForeignKey.stub | 7 - tests/stubs/Models/_dateMutator.stub | 7 - tests/stubs/Models/_nullableDateMutator.stub | 7 - tests/stubs/MultipleNameCases.php | 19 - tests/stubs/NameCases.stub | 19 - tests/stubs/NameCasesSuffix.stub | 19 - tests/stubs/Policies/CategoryPolicy.php | 59 - tests/stubs/Policies/CustomerPolicy.php | 99 - tests/stubs/Policies/ProductPolicy.php | 83 - .../AuthModelsAppServiceProvider.php | 46 - .../Providers/ModelsAppServiceProvider.php | 44 - .../Providers/SkeletonAppServiceProvider.php | 40 - tests/stubs/Requests/CategoriesRequest.php | 46 - tests/stubs/Requests/CustomersRequest.php | 76 - tests/stubs/Requests/OrdersRequest.php | 44 - tests/stubs/Requests/ProductsRequest.php | 66 - tests/stubs/Requests/PublicUsersRequest.php | 55 - tests/stubs/SingleNameCases.php | 19 - tests/stubs/config/auth/_default-guard.stub | 1 - .../stubs/config/auth/_default-passwords.stub | 1 - tests/stubs/config/auth/_guards.stub | 9 - tests/stubs/config/auth/_passport-guards.stub | 1 - tests/stubs/config/auth/_passwords.stub | 6 - tests/stubs/config/auth/_providers.stub | 4 - tests/stubs/config/auth/models-auth.php | 199 - tests/stubs/config/auth/skeleton-auth.php | 153 - tests/stubs/factories/CategoryFactory.php | 25 - tests/stubs/factories/CountryFactory.php | 25 - tests/stubs/factories/CustomerFactory.php | 41 - tests/stubs/factories/OrderFactory.php | 48 - tests/stubs/factories/PaymentFactory.php | 38 - tests/stubs/factories/ProductFactory.php | 45 - ...e_customer_password_reset_tokens_table.php | 32 - tests/stubs/routes/_admin.stub | 5 - tests/stubs/routes/_adminSoftDeletes.stub | 8 - tests/stubs/routes/_api.stub | 5 - tests/stubs/routes/modelsAdmin.php | 131 - tests/stubs/routes/modelsApi.php | 105 - tests/stubs/routes/skeletonAdmin.php | 114 - tests/stubs/routes/skeletonApi.php | 91 - .../seeders/AuthPermissionsSeederExisting.php | 93 - .../seeders/AuthPermissionsSeederNew.php | 48 - tests/stubs/seeders/DefaultUsersSeeder.php | 44 - tests/stubs/seeders/PermissionsSeeder.php | 71 - .../seeders/PermissionsSeederExisting.php | 90 - tests/stubs/seeders/PermissionsSeederNew.php | 45 - tests/stubs/seeders/_authPermissions.stub | 10 - tests/stubs/seeders/_permissions.stub | 8 - .../seeders/_permissionsSoftDeletes.stub | 9 - .../tests/Api/CategoriesControllerTest.stub | 78 - .../stubs/tests/CategoriesControllerTest.stub | 369 -- tests/stubs/tests/OrdersControllerTest.stub | 410 -- tests/stubs/tests/ProductsControllerTest.stub | 674 --- .../SingleValueEnumOrdersControllerTest.stub | 410 -- tests/stubs/tests/_correctDbValues.stub | 15 - tests/stubs/tests/_correctInputs.stub | 15 - .../tests/_differentCorrectDbValues.stub | 15 - .../stubs/tests/_differentCorrectInputs.stub | 15 - tests/stubs/tests/_errors.stub | 15 - tests/stubs/tests/_factoryDbValues.stub | 15 - tests/stubs/tests/_factoryInputs.stub | 15 - tests/stubs/tests/_wrongDbValues.stub | 15 - tests/stubs/tests/_wrongInputs.stub | 15 - .../stubs/views/categories/_actions.blade.php | 41 - .../stubs/views/categories/_filter.blade.php | 30 - tests/stubs/views/categories/_form.blade.php | 17 - tests/stubs/views/categories/_list.blade.php | 33 - tests/stubs/views/categories/_table.blade.php | 35 - tests/stubs/views/categories/index.blade.php | 29 - tests/stubs/views/orders/_details.blade.php | 9 - tests/stubs/views/orders/_form.blade.php | 21 - tests/stubs/views/orders/_list.blade.php | 37 - tests/stubs/views/products/_actions.blade.php | 48 - tests/stubs/views/products/_bulk.blade.php | 9 - .../stubs/views/products/_columns.blade.stub | 27 - tests/stubs/views/products/_details.blade.php | 31 - tests/stubs/views/products/_filter.blade.php | 30 - tests/stubs/views/products/_form.blade.php | 43 - tests/stubs/views/products/_list.blade.php | 75 - tests/stubs/views/products/_table.blade.php | 48 - tests/stubs/views/products/_titles.blade.stub | 14 - tests/stubs/views/products/create.blade.php | 9 - tests/stubs/views/products/edit.blade.php | 9 - tests/stubs/views/products/index.blade.php | 36 - tests/stubs/views/products/products.blade.php | 12 - tests/stubs/views/products/show.blade.php | 7 - workbench/app/Enums/ProductTypes.php | 17 + workbench/app/Http/Controllers/Controller.php | 8 + workbench/app/Models/User.php | 28 + .../app}/Providers/AppServiceProvider.php | 0 workbench/build.php | 81 + workbench/database/factories/UserFactory.php | 26 + .../0001_01_01_000000_create_users_table.php | 43 + ...026_01_01_000001_create_catalog_tables.php | 79 + workbench/generate/GenerateAppTest.php | 56 + workbench/phpunit.generate.xml | 12 + workbench/phpunit.xml | 13 + workbench/resources/views/app.blade.php | 10 + .../resources/views/layouts/admin.blade.php | 20 + workbench/routes/web.php | 5 + workbench/tests/TestCase.php | 79 + 490 files changed, 13812 insertions(+), 27377 deletions(-) delete mode 100644 .github/coverage.svg delete mode 100644 .github/workflows/run-tests.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 config/config.php create mode 100644 config/generators.php create mode 100644 lang/dv/generators.php create mode 100644 lang/dv/phrases.php create mode 100644 lang/en/generators.php create mode 100644 lang/en/phrases.php create mode 100644 pint.json create mode 100644 src/Commands.php delete mode 100644 src/Commands/Auth/BaseAuthGenerateCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthHomeControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthLoginControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthRegisterControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthResetPasswordControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthUpdatePasswordControllerCommand.php delete mode 100644 src/Commands/Auth/Controllers/GenerateAuthVerificationControllerCommand.php delete mode 100644 src/Commands/Auth/GenerateAuthConfigCommand.php delete mode 100644 src/Commands/Auth/GenerateAuthFactoryCommand.php delete mode 100644 src/Commands/Auth/GenerateAuthModelCommand.php delete mode 100644 src/Commands/Auth/GenerateAuthPasswordResetsCommand.php delete mode 100644 src/Commands/Auth/GenerateAuthPermissionsCommand.php delete mode 100644 src/Commands/Auth/GenerateAuthPolicyCommand.php delete mode 100644 src/Commands/Auth/GenerateAuthRequestCommand.php delete mode 100644 src/Commands/BaseGenerateCommand.php delete mode 100644 src/Commands/GenerateApiCommand.php delete mode 100644 src/Commands/GenerateApiControllerCommand.php delete mode 100644 src/Commands/GenerateApiTestCommand.php create mode 100644 src/Commands/GenerateBulkActionsCommand.php create mode 100644 src/Commands/GenerateFiltersCommand.php create mode 100644 src/Commands/GenerateFormCommand.php delete mode 100644 src/Commands/GenerateRequestCommand.php delete mode 100644 src/Commands/MultipleGeneratorCommand.php delete mode 100644 src/Contracts/SchemaResolverInterface.php delete mode 100644 src/FieldTypes/BooleanField.php delete mode 100644 src/FieldTypes/DateField.php delete mode 100644 src/FieldTypes/DateTimeField.php delete mode 100644 src/FieldTypes/DateTypeField.php delete mode 100644 src/FieldTypes/DecimalField.php delete mode 100644 src/FieldTypes/EnumField.php delete mode 100644 src/FieldTypes/Field.php delete mode 100644 src/FieldTypes/ForeignKeyField.php delete mode 100644 src/FieldTypes/IntegerField.php delete mode 100644 src/FieldTypes/JsonField.php delete mode 100644 src/FieldTypes/StringField.php delete mode 100644 src/FieldTypes/TextField.php delete mode 100644 src/FieldTypes/TimeField.php delete mode 100644 src/FieldTypes/YearField.php create mode 100644 src/Fields/BooleanField.php create mode 100644 src/Fields/DateField.php create mode 100644 src/Fields/DateTimeField.php create mode 100644 src/Fields/DecimalField.php create mode 100644 src/Fields/EnumField.php create mode 100644 src/Fields/Field.php create mode 100644 src/Fields/ForeignKeyField.php create mode 100644 src/Fields/IntegerField.php create mode 100644 src/Fields/JsonField.php create mode 100644 src/Fields/StringField.php create mode 100644 src/Fields/TextField.php create mode 100644 src/Fields/TimeField.php create mode 100644 src/Fields/YearField.php delete mode 100644 src/Generators/ApiControllerGenerator.php delete mode 100644 src/Generators/ApiTestGenerator.php delete mode 100644 src/Generators/Auth/AuthConfigGenerator.php delete mode 100644 src/Generators/Auth/AuthFactoryGenerator.php delete mode 100644 src/Generators/Auth/AuthModelGenerator.php delete mode 100644 src/Generators/Auth/AuthPasswordResetsGenerator.php delete mode 100644 src/Generators/Auth/AuthPermissionsGenerator.php delete mode 100644 src/Generators/Auth/AuthPolicyGenerator.php delete mode 100644 src/Generators/Auth/AuthRequestGenerator.php delete mode 100644 src/Generators/Auth/BaseAuthGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthConfirmPasswordControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthForgotPasswordControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthHomeControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthLoginControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthRegisterControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthResetPasswordControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthUpdatePasswordControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/AuthVerificationControllerGenerator.php delete mode 100644 src/Generators/Auth/Controllers/BaseAuthControllerGenerator.php delete mode 100644 src/Generators/BaseGenerator.php create mode 100644 src/Generators/BladeViewsGenerator.php create mode 100644 src/Generators/BulkActionsGenerator.php delete mode 100644 src/Generators/Concerns/GeneratesAuthController.php delete mode 100644 src/Generators/Concerns/GeneratesController.php delete mode 100644 src/Generators/Concerns/GeneratesExport.php delete mode 100644 src/Generators/Concerns/GeneratesFactory.php delete mode 100644 src/Generators/Concerns/GeneratesModel.php delete mode 100644 src/Generators/Concerns/GeneratesPermissions.php delete mode 100644 src/Generators/Concerns/GeneratesPolicy.php delete mode 100644 src/Generators/Concerns/GeneratesRequest.php create mode 100644 src/Generators/FiltersGenerator.php create mode 100644 src/Generators/FormGenerator.php create mode 100644 src/Generators/GeneratedFile.php create mode 100644 src/Generators/Generator.php create mode 100644 src/Generators/InertiaPagesGenerator.php delete mode 100644 src/Generators/RequestGenerator.php create mode 100644 src/Generators/ResourceGenerator.php create mode 100644 src/Generators/TranslationsGenerator.php delete mode 100644 src/Generators/ViewsGenerator.php delete mode 100644 src/IconProviders/BaseIconProvider.php delete mode 100644 src/IconProviders/FontAwesomeProvider.php delete mode 100644 src/IconProviders/MaterialDesignIconicProvider.php delete mode 100644 src/IconProviders/resources/fontawesome-v7.txt delete mode 100644 src/Resolvers/BaseSchemaResolver.php delete mode 100644 src/Resolvers/SchemaResolverMySql.php create mode 100644 src/Resource/MediaCollection.php create mode 100644 src/Resource/ResourceDefinition.php create mode 100644 src/Resource/ResourceOptions.php create mode 100644 src/Resource/ResourceResolver.php create mode 100644 src/Schema/Column.php create mode 100644 src/Schema/ForeignKey.php create mode 100644 src/Schema/RelationResolver.php create mode 100644 src/Schema/Relations/BelongsTo.php create mode 100644 src/Schema/Relations/BelongsToMany.php create mode 100644 src/Schema/Relations/HasMany.php create mode 100644 src/Schema/Relations/Relation.php create mode 100644 src/Schema/SchemaInspector.php create mode 100644 src/Schema/Table.php create mode 100644 src/Support/Imports.php create mode 100644 src/Support/Naming.php create mode 100644 src/Support/PhpFile.php create mode 100644 src/Support/Stub.php delete mode 100644 src/Support/TableProperties.php create mode 100644 src/Support/Translations.php delete mode 100644 stubs/Controllers/Auth/AuthModelSoftDeletesController.stub delete mode 100644 stubs/Controllers/Auth/ConfirmPasswordController.stub delete mode 100644 stubs/Controllers/Auth/ForgotPasswordController.stub delete mode 100644 stubs/Controllers/Auth/HomeController.stub delete mode 100644 stubs/Controllers/Auth/LoginController.stub delete mode 100644 stubs/Controllers/Auth/RegisterController.stub delete mode 100644 stubs/Controllers/Auth/ResetPasswordController.stub delete mode 100644 stubs/Controllers/Auth/UpdatePasswordController.stub delete mode 100644 stubs/Controllers/Auth/UserController.stub delete mode 100644 stubs/Controllers/Auth/VerificationController.stub delete mode 100644 stubs/Controllers/Auth/_registerRulesBody.stub delete mode 100644 stubs/Controllers/ModelApiController.stub delete mode 100644 stubs/Controllers/ModelController.stub delete mode 100644 stubs/Controllers/ModelSoftDeletesController.stub delete mode 100644 stubs/Controllers/_controllerBoolean.stub delete mode 100644 stubs/Controllers/_controllerEagerLoads.stub delete mode 100644 stubs/Controllers/_controllerForeignKey.stub delete mode 100644 stubs/Controllers/_controllerUpdateFill.stub delete mode 100644 stubs/Controllers/_orderBy.stub delete mode 100644 stubs/Exports/ModelExport.stub delete mode 100644 stubs/Exports/_exportRelations.stub delete mode 100644 stubs/Menus/_sidebar.stub delete mode 100644 stubs/Models/AuthModel.stub delete mode 100644 stubs/Models/Model.stub delete mode 100644 stubs/Models/_adminLinkName.stub delete mode 100644 stubs/Models/_authCasts.stub delete mode 100644 stubs/Models/_casts.stub delete mode 100644 stubs/Models/_dateMutator.stub delete mode 100644 stubs/Models/_modelForeignKey.stub delete mode 100644 stubs/Models/_nullableDateMutator.stub delete mode 100644 stubs/Policies/ModelPolicy.stub delete mode 100644 stubs/Policies/ModelSoftDeletesPolicy.stub delete mode 100644 stubs/Policies/_authPolicyMethods.stub delete mode 100644 stubs/Requests/AuthModelRequest.stub delete mode 100644 stubs/Requests/ModelRequest.stub delete mode 100644 stubs/Requests/_authBlankRulesBody.stub delete mode 100644 stubs/Requests/_authRulesBody.stub delete mode 100644 stubs/Requests/_rulesBody.stub create mode 100644 stubs/bulk-actions/BulkActions.stub delete mode 100644 stubs/config/auth/_default-guard.stub delete mode 100644 stubs/config/auth/_default-passwords.stub delete mode 100644 stubs/config/auth/_guards.stub delete mode 100644 stubs/config/auth/_passport-guards.stub delete mode 100644 stubs/config/auth/_passwords.stub delete mode 100644 stubs/config/auth/_providers.stub create mode 100644 stubs/controller/Controller.stub create mode 100644 stubs/export/Export.stub delete mode 100644 stubs/factories/AuthFactory.stub delete mode 100644 stubs/factories/ModelFactory.stub delete mode 100644 stubs/factories/_factoryForeignKey.stub delete mode 100644 stubs/factories/_factoryRequiredRelations.stub create mode 100644 stubs/factory/Factory.stub create mode 100644 stubs/filters/Filters.stub create mode 100644 stubs/form/Form.stub delete mode 100644 stubs/migrations/create_auth_password_reset_tokens_table.stub create mode 100644 stubs/model/Model.stub create mode 100644 stubs/policy/Policy.stub create mode 100644 stubs/resource/Resource.stub delete mode 100644 stubs/routes/_admin.stub delete mode 100644 stubs/routes/_adminSoftDeletes.stub delete mode 100644 stubs/routes/_api.stub create mode 100644 stubs/routes/admin.stub delete mode 100644 stubs/seeders/PermissionsSeeder.stub delete mode 100644 stubs/seeders/_authPermissions.stub delete mode 100644 stubs/seeders/_permissions.stub delete mode 100644 stubs/seeders/_permissionsSoftDeletes.stub create mode 100644 stubs/test/ControllerTest.stub delete mode 100644 stubs/tests/ModelApiControllerTest.stub delete mode 100644 stubs/tests/ModelControllerTest.stub delete mode 100644 stubs/tests/ModelSoftDeletesControllerTest.stub create mode 100644 stubs/views/blade/create.blade.stub create mode 100644 stubs/views/blade/edit.blade.stub create mode 100644 stubs/views/blade/index.blade.stub create mode 100644 stubs/views/blade/show.blade.stub delete mode 100644 stubs/views/model-soft-deletes/_actions.blade.stub delete mode 100644 stubs/views/model-soft-deletes/_filter.blade.stub delete mode 100644 stubs/views/model-soft-deletes/_list.blade.stub delete mode 100644 stubs/views/model-soft-deletes/index.blade.stub delete mode 100644 stubs/views/model/_actions.blade.stub delete mode 100644 stubs/views/model/_bulk.blade.stub delete mode 100644 stubs/views/model/_details.blade.stub delete mode 100644 stubs/views/model/_filter.blade.stub delete mode 100644 stubs/views/model/_form.blade.stub delete mode 100644 stubs/views/model/_list.blade.stub delete mode 100644 stubs/views/model/_table.blade.stub delete mode 100644 stubs/views/model/create.blade.stub delete mode 100644 stubs/views/model/edit.blade.stub delete mode 100644 stubs/views/model/index.blade.stub delete mode 100644 stubs/views/model/model.blade.stub delete mode 100644 stubs/views/model/show.blade.stub create mode 100644 tests/Concerns/AssertsSnapshots.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthConfirmPasswordControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthForgotPasswordControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthHomeControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthLoginControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthRegisterControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthResetPasswordControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthUpdatePasswordControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/Controllers/GenerateAuthVerificationControllerCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/GenerateAuthConfigCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/GenerateAuthFactoryCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/GenerateAuthModelCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/GenerateAuthPasswordResetsCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/GenerateAuthPermissionsCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/GenerateAuthPolicyCommandTest.php delete mode 100644 tests/Feature/Commands/Auth/GenerateAuthRequestCommandTest.php delete mode 100644 tests/Feature/Commands/BaseGenerateCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateAllCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateApiCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateApiControllerCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateApiTestCommandTest.php create mode 100644 tests/Feature/Commands/GenerateCommandsTest.php delete mode 100644 tests/Feature/Commands/GenerateControllerCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateExportCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateFactoryCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateModelCommandTest.php delete mode 100644 tests/Feature/Commands/GeneratePermissionsCommandTest.php delete mode 100644 tests/Feature/Commands/GeneratePolicyCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateRequestCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateRoutesCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateTestCommandTest.php delete mode 100644 tests/Feature/Commands/GenerateViewsCommandTest.php create mode 100644 tests/Feature/GeneratorSnapshotsTest.php rename tests/{TestSupport => Fixtures}/Enums/OrderStatuses.php (82%) rename tests/{TestSupport => Fixtures}/Enums/SingleCaseOrderStatuses.php (78%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144110_create_categories_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144111_create_products_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144112_create_orders_table.php (85%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144113_create_countries_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144113_create_posts_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144114_create_payments_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144115_create_certificates_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144116_create_all_foreigns_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144117_create_customers_table.php (99%) rename tests/{TestSupport/database => Fixtures/migrations}/2024_04_28_144118_create_public_users_table.php (99%) create mode 100644 tests/Fixtures/migrations/2024_04_28_144119_create_tags_table.php rename tests/{TestSupport/database => Fixtures/migrations}/2025_03_27_212600_create_single_value_enum_orders_table.php (84%) delete mode 100644 tests/TestSupport/Providers/TestServiceProvider.php delete mode 100644 tests/Unit/BooleanFieldTest.php delete mode 100644 tests/Unit/DecimalFieldTest.php delete mode 100644 tests/Unit/FieldTest.php delete mode 100644 tests/Unit/ForeignKeyFieldTest.php delete mode 100644 tests/Unit/Generators/ApiControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/ApiTestGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/AuthConfigGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/AuthFactoryGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/AuthModelGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/AuthPasswordResetsGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/AuthPermissionsGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/AuthPolicyGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/AuthRequestGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/BaseAuthGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthConfirmPasswordControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthForgotPasswordControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthHomeControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthLoginControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthRegisterControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthResetPasswordControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthUpdatePasswordControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/Auth/Controllers/AuthVerificationControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/BaseGeneratorTest.php delete mode 100644 tests/Unit/Generators/Concerns/GeneratesAuthControllerTest.php delete mode 100644 tests/Unit/Generators/ControllerGeneratorTest.php delete mode 100644 tests/Unit/Generators/ExportGeneratorTest.php delete mode 100644 tests/Unit/Generators/FactoryGeneratorTest.php delete mode 100644 tests/Unit/Generators/ModelGeneratorTest.php delete mode 100644 tests/Unit/Generators/PermissionsGeneratorTest.php delete mode 100644 tests/Unit/Generators/PolicyGeneratorTest.php delete mode 100644 tests/Unit/Generators/RequestGeneratorTest.php delete mode 100644 tests/Unit/Generators/RoutesGeneratorTest.php delete mode 100644 tests/Unit/Generators/TestGeneratorTest.php delete mode 100644 tests/Unit/Generators/ViewsGeneratorTest.php delete mode 100644 tests/Unit/IconProviderTest.php create mode 100644 tests/Unit/Schema/RelationResolverTest.php create mode 100644 tests/Unit/Schema/SchemaInspectorTest.php delete mode 100644 tests/Unit/SchemaResolverMySqlTest.php delete mode 100644 tests/Unit/StringCaserTest.php delete mode 100644 tests/Unit/StubRendererTest.php create mode 100644 tests/Unit/Support/ImportsTest.php create mode 100644 tests/Unit/Support/NamingTest.php create mode 100644 tests/Unit/Support/PhpFileTest.php create mode 100644 tests/Unit/Support/StubTest.php create mode 100644 tests/snapshots/categories.blade.txt create mode 100644 tests/snapshots/orders.enum-class-and-a-key-to-a-slug.txt create mode 100644 tests/snapshots/posts-with-media.blade.txt create mode 100644 tests/snapshots/posts-with-media.inertia.txt create mode 100644 tests/snapshots/products-nested-in-categories.blade.txt create mode 100644 tests/snapshots/products-nested-in-categories.inertia.txt create mode 100644 tests/snapshots/products.blade.txt create mode 100644 tests/snapshots/products.inertia.txt create mode 100644 tests/snapshots/tags.a-pivot-on-the-other-side.txt delete mode 100644 tests/stubs/AfterAppend.php delete mode 100644 tests/stubs/AfterAppendMarkerRemoved.php delete mode 100644 tests/stubs/BeforeAppend.php delete mode 100644 tests/stubs/Controllers/Api/CategoriesController.php delete mode 100644 tests/stubs/Controllers/Api/ProductsController.php delete mode 100644 tests/stubs/Controllers/CategoriesController.php delete mode 100644 tests/stubs/Controllers/Customer/Auth/ConfirmPasswordController.php delete mode 100644 tests/stubs/Controllers/Customer/Auth/ForgotPasswordController.php delete mode 100644 tests/stubs/Controllers/Customer/Auth/LoginController.php delete mode 100644 tests/stubs/Controllers/Customer/Auth/RegisterController.php delete mode 100644 tests/stubs/Controllers/Customer/Auth/ResetPasswordController.php delete mode 100644 tests/stubs/Controllers/Customer/Auth/UpdatePasswordController.php delete mode 100644 tests/stubs/Controllers/Customer/Auth/VerificationController.php delete mode 100644 tests/stubs/Controllers/Customer/CustomerController.php delete mode 100644 tests/stubs/Controllers/Customer/HomeController.php delete mode 100644 tests/stubs/Controllers/CustomersController.php delete mode 100644 tests/stubs/Controllers/OrdersController.php delete mode 100644 tests/stubs/Controllers/ProductsController.php delete mode 100644 tests/stubs/Controllers/_categoryForeignKey.stub delete mode 100644 tests/stubs/Controllers/_onSaleBoolean.stub delete mode 100644 tests/stubs/Controllers/_onSaleBooleanIndented.stub delete mode 100644 tests/stubs/Controllers/_orderBy.stub delete mode 100644 tests/stubs/Controllers/_ordersEagerLoads.stub delete mode 100644 tests/stubs/Exports/AllForeignsExport.php delete mode 100644 tests/stubs/Exports/CategoriesExport.php delete mode 100644 tests/stubs/Exports/ModelExport.stub delete mode 100644 tests/stubs/Exports/ProductsExport.php delete mode 100644 tests/stubs/Menus/AdminSidebar.php delete mode 100644 tests/stubs/Menus/ModelsAdminSidebar.php delete mode 100644 tests/stubs/Menus/_categoriesSidebar.stub delete mode 100644 tests/stubs/Models/Category.php delete mode 100644 tests/stubs/Models/Customer.php delete mode 100644 tests/stubs/Models/Order.php delete mode 100644 tests/stubs/Models/Product.php delete mode 100644 tests/stubs/Models/PublicUser.php delete mode 100644 tests/stubs/Models/_categoryForeignKey.stub delete mode 100644 tests/stubs/Models/_dateMutator.stub delete mode 100644 tests/stubs/Models/_nullableDateMutator.stub delete mode 100644 tests/stubs/MultipleNameCases.php delete mode 100644 tests/stubs/NameCases.stub delete mode 100644 tests/stubs/NameCasesSuffix.stub delete mode 100644 tests/stubs/Policies/CategoryPolicy.php delete mode 100644 tests/stubs/Policies/CustomerPolicy.php delete mode 100644 tests/stubs/Policies/ProductPolicy.php delete mode 100644 tests/stubs/Providers/AuthModelsAppServiceProvider.php delete mode 100644 tests/stubs/Providers/ModelsAppServiceProvider.php delete mode 100644 tests/stubs/Providers/SkeletonAppServiceProvider.php delete mode 100644 tests/stubs/Requests/CategoriesRequest.php delete mode 100644 tests/stubs/Requests/CustomersRequest.php delete mode 100644 tests/stubs/Requests/OrdersRequest.php delete mode 100644 tests/stubs/Requests/ProductsRequest.php delete mode 100644 tests/stubs/Requests/PublicUsersRequest.php delete mode 100644 tests/stubs/SingleNameCases.php delete mode 100644 tests/stubs/config/auth/_default-guard.stub delete mode 100644 tests/stubs/config/auth/_default-passwords.stub delete mode 100644 tests/stubs/config/auth/_guards.stub delete mode 100644 tests/stubs/config/auth/_passport-guards.stub delete mode 100644 tests/stubs/config/auth/_passwords.stub delete mode 100644 tests/stubs/config/auth/_providers.stub delete mode 100644 tests/stubs/config/auth/models-auth.php delete mode 100644 tests/stubs/config/auth/skeleton-auth.php delete mode 100644 tests/stubs/factories/CategoryFactory.php delete mode 100644 tests/stubs/factories/CountryFactory.php delete mode 100644 tests/stubs/factories/CustomerFactory.php delete mode 100644 tests/stubs/factories/OrderFactory.php delete mode 100644 tests/stubs/factories/PaymentFactory.php delete mode 100644 tests/stubs/factories/ProductFactory.php delete mode 100644 tests/stubs/migrations/create_customer_password_reset_tokens_table.php delete mode 100644 tests/stubs/routes/_admin.stub delete mode 100644 tests/stubs/routes/_adminSoftDeletes.stub delete mode 100644 tests/stubs/routes/_api.stub delete mode 100644 tests/stubs/routes/modelsAdmin.php delete mode 100644 tests/stubs/routes/modelsApi.php delete mode 100644 tests/stubs/routes/skeletonAdmin.php delete mode 100644 tests/stubs/routes/skeletonApi.php delete mode 100644 tests/stubs/seeders/AuthPermissionsSeederExisting.php delete mode 100644 tests/stubs/seeders/AuthPermissionsSeederNew.php delete mode 100644 tests/stubs/seeders/DefaultUsersSeeder.php delete mode 100644 tests/stubs/seeders/PermissionsSeeder.php delete mode 100644 tests/stubs/seeders/PermissionsSeederExisting.php delete mode 100644 tests/stubs/seeders/PermissionsSeederNew.php delete mode 100644 tests/stubs/seeders/_authPermissions.stub delete mode 100644 tests/stubs/seeders/_permissions.stub delete mode 100644 tests/stubs/seeders/_permissionsSoftDeletes.stub delete mode 100644 tests/stubs/tests/Api/CategoriesControllerTest.stub delete mode 100644 tests/stubs/tests/CategoriesControllerTest.stub delete mode 100644 tests/stubs/tests/OrdersControllerTest.stub delete mode 100644 tests/stubs/tests/ProductsControllerTest.stub delete mode 100644 tests/stubs/tests/SingleValueEnumOrdersControllerTest.stub delete mode 100644 tests/stubs/tests/_correctDbValues.stub delete mode 100644 tests/stubs/tests/_correctInputs.stub delete mode 100644 tests/stubs/tests/_differentCorrectDbValues.stub delete mode 100644 tests/stubs/tests/_differentCorrectInputs.stub delete mode 100644 tests/stubs/tests/_errors.stub delete mode 100644 tests/stubs/tests/_factoryDbValues.stub delete mode 100644 tests/stubs/tests/_factoryInputs.stub delete mode 100644 tests/stubs/tests/_wrongDbValues.stub delete mode 100644 tests/stubs/tests/_wrongInputs.stub delete mode 100644 tests/stubs/views/categories/_actions.blade.php delete mode 100644 tests/stubs/views/categories/_filter.blade.php delete mode 100644 tests/stubs/views/categories/_form.blade.php delete mode 100644 tests/stubs/views/categories/_list.blade.php delete mode 100644 tests/stubs/views/categories/_table.blade.php delete mode 100644 tests/stubs/views/categories/index.blade.php delete mode 100644 tests/stubs/views/orders/_details.blade.php delete mode 100644 tests/stubs/views/orders/_form.blade.php delete mode 100644 tests/stubs/views/orders/_list.blade.php delete mode 100644 tests/stubs/views/products/_actions.blade.php delete mode 100644 tests/stubs/views/products/_bulk.blade.php delete mode 100644 tests/stubs/views/products/_columns.blade.stub delete mode 100644 tests/stubs/views/products/_details.blade.php delete mode 100644 tests/stubs/views/products/_filter.blade.php delete mode 100644 tests/stubs/views/products/_form.blade.php delete mode 100644 tests/stubs/views/products/_list.blade.php delete mode 100644 tests/stubs/views/products/_table.blade.php delete mode 100644 tests/stubs/views/products/_titles.blade.stub delete mode 100644 tests/stubs/views/products/create.blade.php delete mode 100644 tests/stubs/views/products/edit.blade.php delete mode 100644 tests/stubs/views/products/index.blade.php delete mode 100644 tests/stubs/views/products/products.blade.php delete mode 100644 tests/stubs/views/products/show.blade.php create mode 100644 workbench/app/Enums/ProductTypes.php create mode 100644 workbench/app/Http/Controllers/Controller.php create mode 100644 workbench/app/Models/User.php rename {tests/stubs => workbench/app}/Providers/AppServiceProvider.php (100%) create mode 100644 workbench/build.php create mode 100644 workbench/database/factories/UserFactory.php create mode 100644 workbench/database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 workbench/database/migrations/2026_01_01_000001_create_catalog_tables.php create mode 100644 workbench/generate/GenerateAppTest.php create mode 100644 workbench/phpunit.generate.xml create mode 100644 workbench/phpunit.xml create mode 100644 workbench/resources/views/app.blade.php create mode 100644 workbench/resources/views/layouts/admin.blade.php create mode 100644 workbench/routes/web.php create mode 100644 workbench/tests/TestCase.php diff --git a/.gitattributes b/.gitattributes index bb6265e..ada44cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,10 @@ -# 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 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..f0298cd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,191 @@ +name: tests + +on: + push: + branches: [master, next-gen] + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +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 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 + + 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/README.md b/README.md index 2d6b321..7c39abd 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # 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 diff --git a/composer.json b/composer.json index ee0edab..5610ca3 100644 --- a/composer.json +++ b/composer.json @@ -1,25 +1,59 @@ { "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" }, "autoload": { "psr-4": { @@ -28,12 +62,27 @@ }, "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/" } }, + "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" + ] + }, "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-next-gen": "2.x-dev" }, "laravel": { "providers": [ @@ -41,6 +90,59 @@ ] } }, + "config": { + "sort-packages": true + }, "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" + } + ] } 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/lang/dv/generators.php b/lang/dv/generators.php new file mode 100644 index 0000000..487ff79 --- /dev/null +++ b/lang/dv/generators.php @@ -0,0 +1,24 @@ + 'މި ކޮމާންޑަށް އެއްފަހަރާ ދެވޭނީ އެއް ޓޭބަލެވެ. ދެއްވީ: :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' => 'މި ކޮމާންޑު ހިންގައިފިނަމަ، ކޮމިޓް ނުކުރާ ހުރިހާ ބަދަލެއް ގެއްލިގެންދާނެ!', +]; 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..de3dee0 --- /dev/null +++ b/lang/en/generators.php @@ -0,0 +1,24 @@ + '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!', +]; 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/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/src/Commands.php b/src/Commands.php new file mode 100644 index 0000000..f0dd1b3 --- /dev/null +++ b/src/Commands.php @@ -0,0 +1,44 @@ + + */ + 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, + 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/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(); - $this->checkTableAndColumns($table, $columns); + if (! $inspector->hasTable($table)) { + throw new TableDoesNotExistException(__('generators::generators.no_table', ['table' => $table])); + } - $this->runGenerator($table, $columns); + $resource = $this->resolveResource($table); - return Command::SUCCESS; + $files = $this->files($resource); + + if (! $this->option('create')) { + $this->printFiles($files); + + return self::SUCCESS; + } + + $this->writeFiles($files); + + 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 = []; + + foreach ($resource->fields as $column => $field) { + $choices[$column] = $field->getLabel() . " ({$column})"; + } - throw new MultipleTablesSuppliedException($msg); + foreach ($resource->belongs_to_many as $relation) { + $choices[$relation->name] = ucfirst($relation->name) . ' (' . __('generators::generators.many_to_many') . ')'; } - if (! Schema::hasTable($table)) { - throw new TableDoesNotExistException("Table '$table' not found!"); + $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; + } } - if (empty($columns)) { - return; + $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'), + )); } - $missingColumns = []; - foreach ($columns as $column) { - if (! Schema::hasColumn($table, $column)) { - $missingColumns[] = $column; + $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..d6d122c --- /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..46ddbf2 --- /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): ?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..2916ddc --- /dev/null +++ b/src/Fields/Field.php @@ -0,0 +1,333 @@ +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 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): ?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..4694d32 --- /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): ?string + { + return "\$this->assertEquals({$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..1ffb92c --- /dev/null +++ b/src/Fields/TimeField.php @@ -0,0 +1,46 @@ +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 deleted file mode 100644 index 1b21f85..0000000 --- a/src/Generators/Auth/AuthConfigGenerator.php +++ /dev/null @@ -1,111 +0,0 @@ - 'guards', - 'renderProviders' => 'providers', - 'renderPasswords' => 'passwords', - 'renderPassportGuards' => 'passport-guards', - ]; - - if ($this->shouldRenderDefaultGuard()) { - $stubs['renderDefaultGuard'] = 'default-guard'; - $stubs['renderDefaultPasswords'] = 'default-passwords'; - } - - return $stubs; - } - - - /** - * Render the views - */ - public function render(): string - { - - $output = ''; - - $views = $this->stubsToRender(); - - foreach ($views as $method => $file_name) { - $output .= "// $file_name\n"; - $output .= $this->{$method}(); - } - - return $output; - } - - public function renderGuards(): string - { - $stub = 'generators::config/auth/_guards.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderProviders(): string - { - $stub = 'generators::config/auth/_providers.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderPasswords(): string - { - $stub = 'generators::config/auth/_passwords.stub'; - - $renderer = $this->getRenderer(); - - return $renderer->replaceStubNames($stub, $this->getTable()); - } - - public function renderPassportGuards(): 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()); - } - - public function renderDefaultPasswords(): 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']); - } -} 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/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/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 @@ -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..4df098b 100644 --- a/src/Generators/FactoryGenerator.php +++ b/src/Generators/FactoryGenerator.php @@ -2,31 +2,38 @@ 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) { + $definition[] = "'{$column}' => {$field->getFactoryValue($imports)},"; + } + + 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..87a4296 --- /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 << + 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..0c7ad9e 100644 --- a/src/Generators/ModelGenerator.php +++ b/src/Generators/ModelGenerator.php @@ -2,22 +2,204 @@ 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\\Model', 'Illuminate\\Database\\Eloquent\\Factories\\HasFactory'], $namespace); + + $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()) { + $traits[] = 'use ' . $imports->add('Illuminate\\Database\\Eloquent\\SoftDeletes') . ';'; + } + + 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(), + '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 { - return $this->renderModel(); + $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 = []; + + foreach ($this->resource->fields as $column => $field) { + if ($cast = $field->getCast($imports)) { + $casts[] = " '{$column}' => {$cast},"; + } + } + + if (! $casts) { + return null; + } + + $casts = implode("\n", $casts); + + return << + */ + protected function casts(): array + { + return [ + {$casts} + ]; + } + PHP; + } + + protected function renderRelations(Imports $imports): ?string + { + $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..b73e514 100644 --- a/src/Generators/RoutesGenerator.php +++ b/src/Generators/RoutesGenerator.php @@ -2,17 +2,137 @@ 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')); + } + /** - * Render the policy + * A new admin routes file. */ - public function render(): string + 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 { - $stub = 'generators::routes/_admin' . ($this->hasSoftDeletes() ? 'SoftDeletes' : '') . '.stub'; + $resource = $this->resource; + + if (trim($code) === '') { + $code = $this->newRoutesFile(); + } + + $code = PhpFile::addImport($code, $resource->controllerClass()); + $code = PhpFile::addImport($code, $resource->bulkActionsClass()); + + if (! str_contains($code, self::MARKER)) { + return rtrim($code) . "\n\n" . $this->renderRoutes() . "\n"; + } + + return PhpFile::insertBeforeMarker($code, self::MARKER, $this->renderRoutes()); + } + + public function renderRoutes(): 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); + } + + /** + * The URI of the resource inside the admin group. + */ + public function uri(): string + { + $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..7bb7bd8 100644 --- a/src/Generators/TestGenerator.php +++ b/src/Generators/TestGenerator.php @@ -3,261 +3,505 @@ 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->destroyTest(), + ...($resource->hasSoftDeletes() ? [$this->restoreTest(), $this->forceDeleteTest()] : []), + $this->bulkDeleteTest(), + ]); + + $guard = $resource->guard() ? ", '{$resource->guard()}'" : ''; + + return Stub::make('test/ControllerTest.stub')->with([ + 'namespace' => $namespace, + 'class' => class_basename($resource->testClass()), + 'tests' => implode("\n\n", $tests), + 'user' => $user, + 'guard' => $guard, + 'imports' => $this->imports->render(), + ])->render(); + } - $use_statements = [ - 'use App\\Exports\\' . StringCaser::pluralStudly($this->getTable()) . 'Export;', - 'use Maatwebsite\\Excel\\Facades\\Excel;', - 'use App\\Models\\' . $this->getModelClass() . ';' - ]; + /* + |-------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------- + */ - /** - * @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; - } - } + protected function listTest(): string + { + $resource = $this->resource; + $variable = '$' . $resource->variable(); - if ($field instanceof EnumField && $field->hasEnumClass()) { - $enum_import = 'use ' . $field->getEnumClass() . ';'; - if (! in_array($enum_import, $use_statements)) { - $use_statements[] = $enum_import; - } - } + $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; + } + + if (! in_array($name, $resource->searchableColumns(), true)) { + 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; + $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); } - public function renderForeignKeyModels($prefix = ''): string + protected function createPageTest(): 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; + + $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); + } + + protected function storeTest(): string + { + $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 $models ? $models . "\n" : ''; + return $this->test("it_creates_a_{$resource->singular()}", $body); } - public function renderErrors(): string + protected function validationTest(): ?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; + + $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()}'"; + } + + if (! $errors) { + return null; } - return $errors; + $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); + } + + protected function storeForbiddenTest(): string + { + $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 renderValues(string $key_callback, string $value_callback, int $tabs = 3, string $value_prefix = ''): string + protected function updateTest(): 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; + $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 $values; + return $this->test("it_updates_a_{$resource->singular()}", $body); } - public function renderWrongInputs(): string + protected function updateForbiddenTest(): string { - return $this->renderValues('getInputName', 'generateWrongValue'); + $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); } - public function renderWrongDbValues(): string + protected function destroyTest(): string { - return $this->renderValues('getName', 'generateWrongDbValue'); + $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 renderCorrectInputs(): string + protected function restoreTest(): 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('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 renderCorrectDbValues(): string + protected function forceDeleteTest(): string { - return $this->renderValues('getName', 'generateCorrectDbValue'); + $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 renderCorrectFactoryValues(): string + protected function bulkDeleteTest(): string { - return $this->renderValues('getName', 'generateCorrectValue'); + $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 renderDifferentCorrectInputs(): 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('getInputName', 'generateDifferentCorrectValue'); + $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(); + + $inputs[] = " '{$name}' => {$value},"; + + $database_value = $field->getDatabaseValue($value); + + if ($database_value !== null) { + $database[] = " '{$column}' => {$database_value},"; + + continue; + } + + if ($assertion = $field->getSavedValueAssertion($variable, $value)) { + $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 renderDifferentCorrectDbValues(): string + protected function parentSetup(): string { - return $this->renderValues('getName', 'generateDifferentCorrectDbValue'); + $resource = $this->resource; + + if (! $resource->isNested()) { + return ''; + } + + $parent = $this->imports->add($resource->parentModelClass()); + + return "\${$resource->parentVariable()} = {$parent}::factory()->create();\n"; + } + + /** + * Product::factory(), or Product::factory()->for($category, 'category') when nested. + */ + protected function factory(): string + { + $resource = $this->resource; + $factory = "{$this->model()}::factory()"; + + if ($resource->isNested()) { + $factory .= "->for(\${$resource->parentVariable()}, '{$resource->parent->getRelationName()}')"; + } + + return $factory; + } + + protected function model(): string + { + return $this->imports->add($this->resource->modelClass()); } - public function renderFactoryInputs(): string + protected function seeName(string $variable): string { - return $this->renderValues('getInputName', 'generateFactoryInput', value_prefix: '$' . $this->getMorph() . '->'); + $resource = $this->resource; + + if (! $resource->field($resource->nameField())) { + return ' ;'; + } + + return " ->assertSee({$variable}->{$resource->nameField()});"; } - public function renderFactoryDbValues(): string + /** + * The assertion on the response: the Inertia page, or the Blade view. + */ + protected function assertPage(string $page, string $inertia, ?string $blade): 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->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..a77ba78 --- /dev/null +++ b/src/Resource/ResourceDefinition.php @@ -0,0 +1,554 @@ + $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, + ) {} + + /* + |-------------------------------------------------------------------------- + | 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 $this->pick($this->index_columns); + } + + /** + * @return array + */ + public function showFields(): array + { + return $this->pick($this->show_columns); + } + + /** + * 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..1dd3ac7 --- /dev/null +++ b/src/Resource/ResourceOptions.php @@ -0,0 +1,71 @@ + $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, + ) {} + + /** + * @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, + ); + } +} diff --git a/src/Resource/ResourceResolver.php b/src/Resource/ResourceResolver.php new file mode 100644 index 0000000..1fbddfa --- /dev/null +++ b/src/Resource/ResourceResolver.php @@ -0,0 +1,402 @@ +inspector->table($table_name); + + $this->ensureColumnsExist($table, $options->columns); + + $key_name = $table->keyName() ?? 'id'; + $skip = (array) config('generators.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; + } + } + + $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, + ); + } + + 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); + } + + 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/bulk-actions/BulkActions.stub b/stubs/bulk-actions/BulkActions.stub new file mode 100644 index 0000000..458ebc2 --- /dev/null +++ b/stubs/bulk-actions/BulkActions.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..0128e97 --- /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..02c5347 --- /dev/null +++ b/stubs/test/ControllerTest.stub @@ -0,0 +1,35 @@ +artisan('permissions:sync'); + } + + {{ 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..d121d0f --- /dev/null +++ b/tests/Feature/Commands/GenerateCommandsTest.php @@ -0,0 +1,211 @@ +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_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/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/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/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..d75f072 --- /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..62d8115 --- /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: LaravelResourceCollection & { 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..56f5d8b --- /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: LaravelResourceCollection & { 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: LaravelResourceCollection & { 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/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; + + protected $fillable = ['name', 'email', 'password']; + + protected $hidden = ['password', 'remember_token']; + + 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_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/generate/GenerateAppTest.php b/workbench/generate/GenerateAppTest.php new file mode 100644 index 0000000..d07f26e --- /dev/null +++ b/workbench/generate/GenerateAppTest.php @@ -0,0 +1,56 @@ +}> + */ + 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']], + ]; + } + + #[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/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')); + } +} From 8c9d0bb1363a38becf47879d4a6377efd398e29a Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 01:11:09 +0500 Subject: [PATCH 2/7] Typecheck and format-check the generated Inertia pages in CI Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + .github/workflows/tests.yml | 48 ++ package.json | 33 ++ pnpm-lock.yaml | 514 ++++++++++++++++++ src/Generators/InertiaPagesGenerator.php | 2 +- tests/snapshots/posts-with-media.inertia.txt | 2 +- .../products-nested-in-categories.inertia.txt | 2 +- tests/snapshots/products.inertia.txt | 2 +- workbench/.prettierrc.json | 4 + workbench/tsconfig.json | 26 + workbench/types/mediapicker.d.ts | 5 + workbench/types/vite.d.ts | 5 + 12 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 workbench/.prettierrc.json create mode 100644 workbench/tsconfig.json create mode 100644 workbench/types/mediapicker.d.ts create mode 100644 workbench/types/vite.d.ts diff --git a/.gitattributes b/.gitattributes index ada44cd..81e7fa6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,6 @@ /testbench.yaml export-ignore /tests export-ignore /workbench export-ignore +/package.json export-ignore +/pnpm-lock.yaml export-ignore +/node_modules export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f0298cd..4fbe2ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,10 @@ concurrency: 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 @@ -170,6 +174,50 @@ jobs: 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 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/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/src/Generators/InertiaPagesGenerator.php b/src/Generators/InertiaPagesGenerator.php index 87a4296..de0a1bd 100644 --- a/src/Generators/InertiaPagesGenerator.php +++ b/src/Generators/InertiaPagesGenerator.php @@ -183,7 +183,7 @@ public function renderIndex(): string import type { {$types_import} } from "./types" interface Props {{$parent_type} - {$items}: LaravelResourceCollection & { data: {$type}[] } + {$items}: Omit & { data: {$type}[] } filters: FiltersData bulkActions: BulkActionsSchema createUrl: string | null diff --git a/tests/snapshots/posts-with-media.inertia.txt b/tests/snapshots/posts-with-media.inertia.txt index 62d8115..22740f9 100644 --- a/tests/snapshots/posts-with-media.inertia.txt +++ b/tests/snapshots/posts-with-media.inertia.txt @@ -72,7 +72,7 @@ import { __ } from "@javaabu/i18n" import type { Post } from "./types" interface Props { - posts: LaravelResourceCollection & { data: Post[] } + posts: Omit & { data: Post[] } filters: FiltersData bulkActions: BulkActionsSchema createUrl: string | null diff --git a/tests/snapshots/products-nested-in-categories.inertia.txt b/tests/snapshots/products-nested-in-categories.inertia.txt index 56f5d8b..a6d1306 100644 --- a/tests/snapshots/products-nested-in-categories.inertia.txt +++ b/tests/snapshots/products-nested-in-categories.inertia.txt @@ -260,7 +260,7 @@ import type { Product, Category } from "./types" interface Props { category: Category - products: LaravelResourceCollection & { data: Product[] } + products: Omit & { data: Product[] } filters: FiltersData bulkActions: BulkActionsSchema createUrl: string | null diff --git a/tests/snapshots/products.inertia.txt b/tests/snapshots/products.inertia.txt index 8cd5784..1ca528f 100644 --- a/tests/snapshots/products.inertia.txt +++ b/tests/snapshots/products.inertia.txt @@ -248,7 +248,7 @@ import { __ } from "@javaabu/i18n" import type { Product } from "./types" interface Props { - products: LaravelResourceCollection & { data: Product[] } + products: Omit & { data: Product[] } filters: FiltersData bulkActions: BulkActionsSchema createUrl: string | null 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/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 +} From db1524d0b8f0c297b98eadecd2dd5ae1920d2e51 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 01:14:10 +0500 Subject: [PATCH 3/7] Media collections with javaabu/mediapicker --media=featured_image:image,gallery:image:multiple gives the model attachment collections, the form MediaPickerFields, the show page the media, the routes the admin media library, and a test that attaches media from the user's library when the admin users are media owners. Refs #10 Co-Authored-By: Claude Opus 5.5 --- src/Generators/RoutesGenerator.php | 34 ++++++++++-- src/Generators/TestGenerator.php | 54 +++++++++++++++++++ workbench/app/Models/User.php | 18 ++++++- .../2026_01_01_000000_create_media_table.php | 32 +++++++++++ .../2026_01_01_000002_create_posts_table.php | 24 +++++++++ workbench/generate/GenerateAppTest.php | 2 + 6 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 workbench/database/migrations/2026_01_01_000000_create_media_table.php create mode 100644 workbench/database/migrations/2026_01_01_000002_create_posts_table.php diff --git a/src/Generators/RoutesGenerator.php b/src/Generators/RoutesGenerator.php index b73e514..8459a06 100644 --- a/src/Generators/RoutesGenerator.php +++ b/src/Generators/RoutesGenerator.php @@ -67,14 +67,42 @@ public function mergeInto(string $code): string $code = PhpFile::addImport($code, $resource->controllerClass()); $code = PhpFile::addImport($code, $resource->bulkActionsClass()); - if (! str_contains($code, self::MARKER)) { - return rtrim($code) . "\n\n" . $this->renderRoutes() . "\n"; + $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}'"); } - return PhpFile::insertBeforeMarker($code, self::MARKER, $this->renderRoutes()); + $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()); diff --git a/src/Generators/TestGenerator.php b/src/Generators/TestGenerator.php index 7bb7bd8..1eeb592 100644 --- a/src/Generators/TestGenerator.php +++ b/src/Generators/TestGenerator.php @@ -50,6 +50,7 @@ public function renderTest(): string $this->editPageTest(), $this->updateTest(), $this->updateForbiddenTest(), + $this->mediaTest(), $this->destroyTest(), ...($resource->hasSoftDeletes() ? [$this->restoreTest(), $this->forceDeleteTest()] : []), $this->bulkDeleteTest(), @@ -288,6 +289,59 @@ protected function updateForbiddenTest(): string return $this->test("it_does_not_update_{$resource->plural()}_without_permission", $body); } + /** + * Picks media from the user's library, when the admin users have one + * (javaabu/mediapicker's MediaOwner). + */ + protected function mediaTest(): ?string + { + $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; + } + + $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); + } + protected function destroyTest(): string { $resource = $this->resource; diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index 1b0c06b..203ee28 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -5,19 +5,35 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; +use Javaabu\Mediapicker\Concerns\OwnsMedia; +use Javaabu\Mediapicker\Contracts\MediaOwner; use Javaabu\Permissions\Traits\HasRoles; +use Spatie\MediaLibrary\InteractsWithMedia; +use Spatie\MediaLibrary\MediaCollections\Models\Media; -class User extends Authenticatable +class User extends Authenticatable implements MediaOwner { /** @use HasFactory */ 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 [ 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_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/generate/GenerateAppTest.php b/workbench/generate/GenerateAppTest.php index d07f26e..6d374f5 100644 --- a/workbench/generate/GenerateAppTest.php +++ b/workbench/generate/GenerateAppTest.php @@ -39,6 +39,8 @@ public static function commands(): array ['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']], ]; } From 0829ce9304993d232a992a53c22fd2aac2cc794a Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 01:15:45 +0500 Subject: [PATCH 4/7] Optional javaabu/translatable support Tables with the translations and lang columns get JsonTranslatable models, translatable stencil fields and tests with Dhivehi translations, when javaabu/translatable is installed. Without it, the same table gives plain models. --translatable picks the columns, generators.translatable turns it off. Refs #16 Co-Authored-By: Claude Opus 5.5 --- src/Generators/TestGenerator.php | 21 ++++ stubs/test/ControllerTest.stub | 2 + tests/Feature/TranslatableTest.php | 107 ++++++++++++++++++ .../2024_04_28_144120_create_pages_table.php | 26 +++++ ...026_01_01_000003_create_articles_table.php | 26 +++++ workbench/generate/GenerateAppTest.php | 2 + 6 files changed, 184 insertions(+) create mode 100644 tests/Feature/TranslatableTest.php create mode 100644 tests/Fixtures/migrations/2024_04_28_144120_create_pages_table.php create mode 100644 workbench/database/migrations/2026_01_01_000003_create_articles_table.php diff --git a/src/Generators/TestGenerator.php b/src/Generators/TestGenerator.php index 1eeb592..219615c 100644 --- a/src/Generators/TestGenerator.php +++ b/src/Generators/TestGenerator.php @@ -58,12 +58,22 @@ public function renderTest(): string $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']);"; + } + return Stub::make('test/ControllerTest.stub')->with([ 'namespace' => $namespace, 'class' => class_basename($resource->testClass()), 'tests' => implode("\n\n", $tests), 'user' => $user, 'guard' => $guard, + 'traits' => $traits, + 'setUp' => $set_up, 'imports' => $this->imports->render(), ])->render(); } @@ -447,6 +457,17 @@ protected function inputs(string $set): array $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); diff --git a/stubs/test/ControllerTest.stub b/stubs/test/ControllerTest.stub index 02c5347..a7f003f 100644 --- a/stubs/test/ControllerTest.stub +++ b/stubs/test/ControllerTest.stub @@ -7,12 +7,14 @@ namespace {{ namespace }}; class {{ class }} extends TestCase { use RefreshDatabase; + {{ traits }} protected function setUp(): void { parent::setUp(); $this->artisan('permissions:sync'); + {{ setUp }} } {{ tests }} 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/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/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/generate/GenerateAppTest.php b/workbench/generate/GenerateAppTest.php index 6d374f5..6bac13c 100644 --- a/workbench/generate/GenerateAppTest.php +++ b/workbench/generate/GenerateAppTest.php @@ -41,6 +41,8 @@ public static function commands(): array ['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']], ]; } From 4a34a1a4976789b4bbf02b201e775b88c0f7b7e0 Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 01:23:46 +0500 Subject: [PATCH 5/7] Multi-auth generator: generate:auth creates a javaabu/auth user type generate:auth customers registers the user type (UserTypes::register), adds its session guard, user provider and password broker to config/auth.php, its auth routes (Route::authRoutes) and the admins' account actions (Route::userAccountActions), and generates the admin pages of its users with a password field and the account status. The model extends Javaabu\Auth\Models\User. --migration writes the table's migration with UserSchema::columns() first. auth v3 ships the auth controllers and pages, so none are generated. Refs #8 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 2 +- lang/dv/generators.php | 1 + lang/en/generators.php | 1 + src/Commands.php | 2 + src/Commands/GenerateAuthCommand.php | 109 ++++++++++++++++++ src/Fields/DateField.php | 2 +- src/Fields/DateTimeField.php | 2 +- src/Fields/Field.php | 10 +- src/Fields/JsonField.php | 2 +- src/Fields/PasswordField.php | 78 +++++++++++++ src/Fields/TimeField.php | 4 +- src/Generators/Auth/AuthConfigGenerator.php | 56 +++++++++ .../Auth/AuthMigrationGenerator.php | 29 +++++ src/Generators/Auth/AuthRoutesGenerator.php | 70 +++++++++++ src/Generators/Auth/UserTypeGenerator.php | 54 +++++++++ src/Generators/FactoryGenerator.php | 10 ++ src/Generators/ModelGenerator.php | 23 +++- src/Generators/TestGenerator.php | 4 +- src/Resource/ResourceDefinition.php | 13 ++- src/Resource/ResourceOptions.php | 2 + src/Resource/ResourceResolver.php | 34 ++++++ src/Support/PhpFile.php | 67 +++++++++++ stubs/auth/migration.stub | 24 ++++ stubs/model/Model.stub | 2 +- stubs/test/ControllerTest.stub | 2 +- .../Feature/Commands/GenerateCommandsTest.php | 66 +++++++++++ tests/snapshots/posts-with-media.blade.txt | 2 +- ...26_01_01_000004_create_customers_table.php | 24 ++++ workbench/generate/GenerateAppTest.php | 2 + .../tests/Feature/GeneratedUserTypeTest.php | 49 ++++++++ 30 files changed, 730 insertions(+), 16 deletions(-) create mode 100644 src/Commands/GenerateAuthCommand.php create mode 100644 src/Fields/PasswordField.php create mode 100644 src/Generators/Auth/AuthConfigGenerator.php create mode 100644 src/Generators/Auth/AuthMigrationGenerator.php create mode 100644 src/Generators/Auth/AuthRoutesGenerator.php create mode 100644 src/Generators/Auth/UserTypeGenerator.php create mode 100644 stubs/auth/migration.stub create mode 100644 workbench/database/migrations/2026_01_01_000004_create_customers_table.php create mode 100644 workbench/tests/Feature/GeneratedUserTypeTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fbe2ec..1642095 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -84,7 +84,7 @@ jobs: php workbench/build.php vendor/bin/phpunit -c workbench/phpunit.generate.xml vendor/bin/phpunit -c workbench/phpunit.xml - vendor/bin/pint --test build/app/app build/app/database/factories build/app/tests/Feature build/app/routes + 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 diff --git a/lang/dv/generators.php b/lang/dv/generators.php index 487ff79..8a16e98 100644 --- a/lang/dv/generators.php +++ b/lang/dv/generators.php @@ -21,4 +21,5 @@ 'next_steps' => 'ކުރިއަށް ކުރަންޖެހޭ ކަންކަން', 'rolled_back' => 'ޖެނެރޭޓަރުން ގެނައި ބަދަލުތައް އަނބުރާ ގެނެވިއްޖެ.', 'rollback_warning' => 'މި ކޮމާންޑު ހިންގައިފިނަމަ، ކޮމިޓް ނުކުރާ ހުރިހާ ބަދަލެއް ގެއްލިގެންދާނެ!', + 'auth_migrate' => ':table ގެ މައިގްރޭޝަން ލިޔެވިއްޖެ. ތިޔަބޭފުޅާގެ ކޮލަމްތައް އިތުރުކޮށް، php artisan migrate ހިންގައި، ޔޫޒަރ ޓައިޕް ހެދުމަށް މި ކޮމާންޑު އަލުން ހިންގާ.', ]; diff --git a/lang/en/generators.php b/lang/en/generators.php index de3dee0..79c1d31 100644 --- a/lang/en/generators.php +++ b/lang/en/generators.php @@ -21,4 +21,5 @@ '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.', ]; diff --git a/src/Commands.php b/src/Commands.php index f0dd1b3..844038d 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -3,6 +3,7 @@ namespace Javaabu\Generators; use Javaabu\Generators\Commands\GenerateAllCommand; +use Javaabu\Generators\Commands\GenerateAuthCommand; use Javaabu\Generators\Commands\GenerateBulkActionsCommand; use Javaabu\Generators\Commands\GenerateControllerCommand; use Javaabu\Generators\Commands\GenerateExportCommand; @@ -38,6 +39,7 @@ public static function all(): array GenerateViewsCommand::class, GenerateTestCommand::class, GenerateExportCommand::class, + GenerateAuthCommand::class, GenerateRollbackCommand::class, ]; } 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/Fields/DateField.php b/src/Fields/DateField.php index d6d122c..321f1c2 100644 --- a/src/Fields/DateField.php +++ b/src/Fields/DateField.php @@ -31,7 +31,7 @@ public function getOtherValidValue(): string return "'2025-11-06'"; } - public function getSavedValueAssertion(string $variable, string $input): ?string + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string { return "\$this->assertEquals({$input}, {$variable}->{$this->getName()}?->format('Y-m-d'));"; } diff --git a/src/Fields/DateTimeField.php b/src/Fields/DateTimeField.php index 46ddbf2..414a6b1 100644 --- a/src/Fields/DateTimeField.php +++ b/src/Fields/DateTimeField.php @@ -53,7 +53,7 @@ public function getDatabaseValue(string $input): ?string return null; } - public function getSavedValueAssertion(string $variable, string $input): ?string + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string { return "\$this->assertEquals({$input}, {$variable}->{$this->getName()}?->format('Y-m-d H:i'));"; } diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 2916ddc..2cca969 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -110,6 +110,14 @@ 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. */ @@ -311,7 +319,7 @@ public function getDatabaseValue(string $input): ?string /** * Code that asserts the saved value, when it isn't compared in the database. */ - public function getSavedValueAssertion(string $variable, string $input): ?string + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string { return null; } diff --git a/src/Fields/JsonField.php b/src/Fields/JsonField.php index 4694d32..ab5ac3b 100644 --- a/src/Fields/JsonField.php +++ b/src/Fields/JsonField.php @@ -84,7 +84,7 @@ public function getDatabaseValue(string $input): ?string return null; } - public function getSavedValueAssertion(string $variable, string $input): ?string + 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/TimeField.php b/src/Fields/TimeField.php index 1ffb92c..ae31cab 100644 --- a/src/Fields/TimeField.php +++ b/src/Fields/TimeField.php @@ -2,6 +2,8 @@ namespace Javaabu\Generators\Fields; +use Javaabu\Generators\Support\Imports; + class TimeField extends Field { public function isSortable(): bool @@ -39,7 +41,7 @@ public function getDatabaseValue(string $input): ?string return null; } - public function getSavedValueAssertion(string $variable, string $input): ?string + public function getSavedValueAssertion(string $variable, string $input, ?Imports $imports = null): ?string { return "\$this->assertEquals({$input}, substr((string) {$variable}->{$this->getName()}, 0, 5));"; } diff --git a/src/Generators/Auth/AuthConfigGenerator.php b/src/Generators/Auth/AuthConfigGenerator.php new file mode 100644 index 0000000..9037648 --- /dev/null +++ b/src/Generators/Auth/AuthConfigGenerator.php @@ -0,0 +1,56 @@ +renderConfig(), fn (string $code) => $this->mergeInto($code), 'auth config')]; + } + + protected function types(): UserTypeGenerator + { + return new UserTypeGenerator($this->resource); + } + + public function mergeInto(string $code): string + { + $types = $this->types(); + $provider = $this->resource->table(); + + $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 PhpFile::addArrayEntry($code, 'passwords', $types->broker(), $this->brokerEntry()); + } + + public function renderConfig(): string + { + return "// 'guards'\n{$this->guardEntry()}\n\n// 'providers'\n{$this->providerEntry()}\n\n// 'passwords'\n{$this->brokerEntry()}"; + } + + protected function guardEntry(): string + { + return "'{$this->types()->guard()}' => [\n 'driver' => 'session',\n 'provider' => '{$this->resource->table()}',\n],"; + } + + protected function providerEntry(): string + { + return "'{$this->resource->table()}' => [\n 'driver' => 'eloquent',\n 'model' => {$this->resource->model()}::class,\n],"; + } + + protected function brokerEntry(): string + { + 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/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/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/UserTypeGenerator.php b/src/Generators/Auth/UserTypeGenerator.php new file mode 100644 index 0000000..f77b8f8 --- /dev/null +++ b/src/Generators/Auth/UserTypeGenerator.php @@ -0,0 +1,54 @@ +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/FactoryGenerator.php b/src/Generators/FactoryGenerator.php index 4df098b..93ca57e 100644 --- a/src/Generators/FactoryGenerator.php +++ b/src/Generators/FactoryGenerator.php @@ -25,9 +25,19 @@ public function renderFactory(): string $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()), diff --git a/src/Generators/ModelGenerator.php b/src/Generators/ModelGenerator.php index 0c7ad9e..78a1397 100644 --- a/src/Generators/ModelGenerator.php +++ b/src/Generators/ModelGenerator.php @@ -22,14 +22,19 @@ public function renderModel(): string { $resource = $this->resource; $namespace = $resource->namespace('models'); - $imports = new Imports(['Illuminate\\Database\\Eloquent\\Model', 'Illuminate\\Database\\Eloquent\\Factories\\HasFactory'], $namespace); + $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()); $traits = ["/** @use HasFactory<{$factory}> */", 'use HasFactory;']; $implements = []; - if ($resource->hasSoftDeletes()) { + if ($resource->hasSoftDeletes() && ! $resource->isUserType()) { $traits[] = 'use ' . $imports->add('Illuminate\\Database\\Eloquent\\SoftDeletes') . ';'; } @@ -53,6 +58,7 @@ public function renderModel(): string 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())), @@ -100,7 +106,14 @@ 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},"; } @@ -112,6 +125,10 @@ protected function renderCasts(Imports $imports): ?string $casts = implode("\n", $casts); + if ($user_type) { + $casts = " ...parent::casts(),\n{$casts}"; + } + return <<resource->media as $media) { - $single = $media->multiple ? '' : "\n ->singleFile()"; + $single = $media->multiple ? '' : "\n ->singleFile()"; $collections[] = <<addAttachmentCollection('{$media->name}'){$single} diff --git a/src/Generators/TestGenerator.php b/src/Generators/TestGenerator.php index 219615c..f727944 100644 --- a/src/Generators/TestGenerator.php +++ b/src/Generators/TestGenerator.php @@ -72,7 +72,7 @@ public function renderTest(): string 'tests' => implode("\n\n", $tests), 'user' => $user, 'guard' => $guard, - 'traits' => $traits, + 'traits_before' => $traits, 'setUp' => $set_up, 'imports' => $this->imports->render(), ])->render(); @@ -478,7 +478,7 @@ protected function inputs(string $set): array continue; } - if ($assertion = $field->getSavedValueAssertion($variable, $value)) { + if ($assertion = $field->getSavedValueAssertion($variable, $value, $this->imports)) { $assertions[] = $assertion; } } diff --git a/src/Resource/ResourceDefinition.php b/src/Resource/ResourceDefinition.php index a77ba78..d0c6a76 100644 --- a/src/Resource/ResourceDefinition.php +++ b/src/Resource/ResourceDefinition.php @@ -47,8 +47,17 @@ public function __construct( 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 @@ -408,7 +417,7 @@ public function formFields(): array */ public function indexFields(): array { - return $this->pick($this->index_columns); + return array_filter($this->pick($this->index_columns), fn (Field $field) => $field->isShown()); } /** @@ -416,7 +425,7 @@ public function indexFields(): array */ public function showFields(): array { - return $this->pick($this->show_columns); + return array_filter($this->pick($this->show_columns), fn (Field $field) => $field->isShown()); } /** diff --git a/src/Resource/ResourceOptions.php b/src/Resource/ResourceOptions.php index 1dd3ac7..dc196ac 100644 --- a/src/Resource/ResourceOptions.php +++ b/src/Resource/ResourceOptions.php @@ -25,6 +25,7 @@ public function __construct( public ?string $views = null, public array|bool|null $translatable = null, public ?string $user_type = null, + public ?string $user_guard = null, ) {} /** @@ -66,6 +67,7 @@ public static function fromArray(array $options): self 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 index 1fbddfa..94349ad 100644 --- a/src/Resource/ResourceResolver.php +++ b/src/Resource/ResourceResolver.php @@ -14,6 +14,7 @@ use Javaabu\Generators\Fields\ForeignKeyField; use Javaabu\Generators\Fields\IntegerField; use Javaabu\Generators\Fields\JsonField; +use Javaabu\Generators\Fields\PasswordField; use Javaabu\Generators\Fields\StringField; use Javaabu\Generators\Fields\TextField; use Javaabu\Generators\Fields\TimeField; @@ -46,6 +47,10 @@ public function resolve(string $table_name, ?ResourceOptions $options = null): R $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) { @@ -66,6 +71,10 @@ public function resolve(string $table_name, ?ResourceOptions $options = null): R } } + if ($options->user_type) { + $fields = $this->addUserTypeFields($table, $fields); + } + $parent = null; $parent_relation = null; @@ -109,9 +118,34 @@ public function resolve(string $table_name, ?ResourceOptions $options = null): R 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; diff --git a/src/Support/PhpFile.php b/src/Support/PhpFile.php index 00d59b0..3b9be2b 100644 --- a/src/Support/PhpFile.php +++ b/src/Support/PhpFile.php @@ -107,6 +107,73 @@ public static function insertBeforeMarker(string $code, string $marker, string $ 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; 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 @@ +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_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_rejects_several_tables(): void { diff --git a/tests/snapshots/posts-with-media.blade.txt b/tests/snapshots/posts-with-media.blade.txt index d75f072..91d8024 100644 --- a/tests/snapshots/posts-with-media.blade.txt +++ b/tests/snapshots/posts-with-media.blade.txt @@ -50,7 +50,7 @@ class Post extends Model implements HasAttachments public function registerAttachmentCollections(): void { $this->addAttachmentCollection('featured_image') - ->singleFile() + ->singleFile() ->acceptsFile(fn (File $file) => FileTypes::isAllowed($file->mimeType, 'image')); $this->addAttachmentCollection('gallery') 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 index 6bac13c..d9fc281 100644 --- a/workbench/generate/GenerateAppTest.php +++ b/workbench/generate/GenerateAppTest.php @@ -43,6 +43,8 @@ public static function commands(): array ['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']], ]; } 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'); + } +} From f33cea9d5466298950220fa2ee0aabbac56d2f0f Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 01:24:29 +0500 Subject: [PATCH 6/7] Explain pivot tables, test the admin guard and Dhivehi messages Co-Authored-By: Claude Opus 5.5 --- lang/dv/generators.php | 1 + lang/en/generators.php | 1 + src/Commands/GenerateCommand.php | 15 +++++++ .../Feature/Commands/GenerateCommandsTest.php | 39 +++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/lang/dv/generators.php b/lang/dv/generators.php index 8a16e98..7473391 100644 --- a/lang/dv/generators.php +++ b/lang/dv/generators.php @@ -22,4 +22,5 @@ 'rolled_back' => 'ޖެނެރޭޓަރުން ގެނައި ބަދަލުތައް އަނބުރާ ގެނެވިއްޖެ.', 'rollback_warning' => 'މި ކޮމާންޑު ހިންގައިފިނަމަ، ކޮމިޓް ނުކުރާ ހުރިހާ ބަދަލެއް ގެއްލިގެންދާނެ!', 'auth_migrate' => ':table ގެ މައިގްރޭޝަން ލިޔެވިއްޖެ. ތިޔަބޭފުޅާގެ ކޮލަމްތައް އިތުރުކޮށް، php artisan migrate ހިންގައި، ޔޫޒަރ ޓައިޕް ހެދުމަށް މި ކޮމާންޑު އަލުން ހިންގާ.', + 'pivot_table' => ':table އަކީ :first އާއި :second ގުޅުވާ ޕިވޮޓް ޓޭބަލެވެ: އެ ދެ ޓޭބަލް ޖެނެރޭޓްކުރާ، އޭރުން ގިނަ-ގިނަ ގުޅުމާއި ގިނަ އެއްޗެހި ހޮވޭ ސެލެކްޓެއް ލިބޭނެ.', ]; diff --git a/lang/en/generators.php b/lang/en/generators.php index 79c1d31..678a7c3 100644 --- a/lang/en/generators.php +++ b/lang/en/generators.php @@ -22,4 +22,5 @@ '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/src/Commands/GenerateCommand.php b/src/Commands/GenerateCommand.php index b5a283b..0281830 100644 --- a/src/Commands/GenerateCommand.php +++ b/src/Commands/GenerateCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use InvalidArgumentException; use Javaabu\Generators\Exceptions\MultipleTablesSuppliedException; use Javaabu\Generators\Exceptions\TableDoesNotExistException; use Javaabu\Generators\Fields\ForeignKeyField; @@ -13,6 +14,7 @@ use Javaabu\Generators\Resource\ResourceDefinition; use Javaabu\Generators\Resource\ResourceOptions; use Javaabu\Generators\Resource\ResourceResolver; +use Javaabu\Generators\Schema\RelationResolver; use Javaabu\Generators\Schema\SchemaInspector; use function Laravel\Prompts\multiselect; @@ -73,6 +75,19 @@ public function handle(): int 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], + ])); + } + $resource = $this->resolveResource($table); $files = $this->files($resource); diff --git a/tests/Feature/Commands/GenerateCommandsTest.php b/tests/Feature/Commands/GenerateCommandsTest.php index 5d2f3bf..e1487b4 100644 --- a/tests/Feature/Commands/GenerateCommandsTest.php +++ b/tests/Feature/Commands/GenerateCommandsTest.php @@ -3,6 +3,7 @@ namespace Javaabu\Generators\Tests\Feature\Commands; use Illuminate\Filesystem\Filesystem; +use InvalidArgumentException; use Javaabu\Generators\Exceptions\ColumnDoesNotExistException; use Javaabu\Generators\Exceptions\MultipleTablesSuppliedException; use Javaabu\Generators\Exceptions\TableDoesNotExistException; @@ -243,6 +244,44 @@ public function it_prints_a_user_type_without_create(): void ->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 { From cc3f39d601dec42ff06b7589ba3ad4487d43153a Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Fri, 6 Nov 2026 01:32:51 +0500 Subject: [PATCH 7/7] Docs, llms.txt, Laravel Boost guideline and skill, AGENTS.md Docusaurus docs for every command, option, config key and generated part, the upgrade guide from v1, Dhivehi phrases to review, and tests that every page is linked from llms.txt and every link resolves. A generated admin user type gets HasRoles. Closes #13 Closes #14 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 38 +++++ CHANGELOG.md | 20 ++- CLAUDE.md | 1 + README.md | 42 ++++- composer.json | 39 +++-- docs/advanced-usage/_category_.json | 3 +- docs/advanced-usage/configuration.md | 47 ++++++ docs/advanced-usage/customising-stubs.md | 60 ++++++- .../databases-and-column-types.md | 41 +++++ docs/advanced-usage/dhivehi-and-rtl.md | 34 ++++ docs/advanced-usage/extending.md | 97 +++++++++++ docs/advanced-usage/the-generated-tests.md | 57 +++++++ docs/advanced-usage/translatable.md | 45 +++++ docs/ai-assistants.md | 26 +++ docs/basic-usage/_category_.json | 2 +- docs/basic-usage/auth-generators.md | 157 ------------------ docs/basic-usage/available-generators.md | 141 +++++++++------- docs/basic-usage/blade-and-inertia.md | 99 +++++++++++ docs/basic-usage/choosing-fields.md | 48 ++++++ docs/basic-usage/enums.md | 47 ++++++ docs/basic-usage/generating-a-resource.md | 109 ++++++++++++ docs/basic-usage/media.md | 70 ++++++++ docs/basic-usage/nested-resources.md | 31 ++++ docs/basic-usage/relations.md | 114 +++++++++++++ docs/basic-usage/rolling-back-changes.md | 10 +- docs/basic-usage/user-types.md | 100 +++++++++++ docs/basic-usage/using-enum-classes.md | 32 ---- docs/basic-usage/using-generators.md | 157 ------------------ docs/changelog.md | 2 +- docs/installation-and-setup.md | 85 +++------- docs/intro.md | 113 +++++-------- docs/limitations/_category_.json | 10 -- docs/limitations/supported-drivers.md | 6 - docs/limitations/supported-field-types.md | 23 --- docs/metadata.json | 4 +- docs/requirements.md | 22 ++- docs/upgrading.md | 72 ++++++++ llms.txt | 43 +++++ resources/boost/guidelines/core.blade.php | 14 ++ .../boost/skills/javaabu-generators/SKILL.md | 96 +++++++++++ .../references/reference.md | 72 ++++++++ src/Generators/ModelGenerator.php | 5 + .../Feature/Commands/GenerateCommandsTest.php | 10 ++ tests/Unit/DocsTest.php | 50 ++++++ 44 files changed, 1672 insertions(+), 622 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/advanced-usage/configuration.md create mode 100644 docs/advanced-usage/databases-and-column-types.md create mode 100644 docs/advanced-usage/dhivehi-and-rtl.md create mode 100644 docs/advanced-usage/extending.md create mode 100644 docs/advanced-usage/the-generated-tests.md create mode 100644 docs/advanced-usage/translatable.md create mode 100644 docs/ai-assistants.md delete mode 100644 docs/basic-usage/auth-generators.md create mode 100644 docs/basic-usage/blade-and-inertia.md create mode 100644 docs/basic-usage/choosing-fields.md create mode 100644 docs/basic-usage/enums.md create mode 100644 docs/basic-usage/generating-a-resource.md create mode 100644 docs/basic-usage/media.md create mode 100644 docs/basic-usage/nested-resources.md create mode 100644 docs/basic-usage/relations.md create mode 100644 docs/basic-usage/user-types.md delete mode 100644 docs/basic-usage/using-enum-classes.md delete mode 100644 docs/basic-usage/using-generators.md delete mode 100644 docs/limitations/_category_.json delete mode 100644 docs/limitations/supported-drivers.md delete mode 100644 docs/limitations/supported-field-types.md create mode 100644 docs/upgrading.md create mode 100644 llms.txt create mode 100644 resources/boost/guidelines/core.blade.php create mode 100644 resources/boost/skills/javaabu-generators/SKILL.md create mode 100644 resources/boost/skills/javaabu-generators/references/reference.md create mode 100644 tests/Unit/DocsTest.php 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 7c39abd..a523943 100644 --- a/README.md +++ b/README.md @@ -5,23 +5,53 @@ [![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 5610ca3..8795eb5 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,18 @@ "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": { "Javaabu\\Generators\\": "src/" @@ -69,17 +81,6 @@ "Tests\\": "build/app/tests/" } }, - "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" - ] - }, "extra": { "branch-alias": { "dev-next-gen": "2.x-dev" @@ -91,7 +92,8 @@ } }, "config": { - "sort-packages": true + "sort-packages": true, + "process-timeout": 0 }, "minimum-stability": "dev", "prefer-stable": true, @@ -144,5 +146,16 @@ "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/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/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/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/Generators/ModelGenerator.php b/src/Generators/ModelGenerator.php index 78a1397..d0fdd6a 100644 --- a/src/Generators/ModelGenerator.php +++ b/src/Generators/ModelGenerator.php @@ -38,6 +38,11 @@ public function renderModel(): string $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') . ';'; diff --git a/tests/Feature/Commands/GenerateCommandsTest.php b/tests/Feature/Commands/GenerateCommandsTest.php index e1487b4..6b6b1ac 100644 --- a/tests/Feature/Commands/GenerateCommandsTest.php +++ b/tests/Feature/Commands/GenerateCommandsTest.php @@ -235,6 +235,16 @@ public function it_generates_a_user_type(): void $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 { 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}"); + } + } + } +}