diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 000000000..2c90ae7e7 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,2 @@ +> 0.25% +not dead diff --git a/.gitattributes b/.gitattributes index 7eccc54e3..c6aa027df 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,6 +11,7 @@ /docs export-ignore /README.md export-ignore /ABOUT.md export-ignore -/resources/assets/js export-ignore -/resources/assets/sass export-ignore +/resources/js export-ignore +/resources/sass export-ignore /packages export-ignore +/src/Dev export-ignore diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..b2df294c0 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,47 @@ +name: Playwright Tests +#on: +# push: +# branches: [ main ] +# pull_request: +# branches: [ main ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, dom, fileinfo, mysql + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + working-directory: tests-e2e + - name: Install Playwright Browsers + run: npx playwright install --with-deps + working-directory: tests-e2e + - name: Install Composer dependencies + run: composer install + working-directory: tests-e2e/site + - name: Create .env + run: cp .env.e2e.ci .env + working-directory: tests-e2e/site + - name: Generate app key + run: php artisan key:generate + working-directory: tests-e2e/site + - name: Setup DB + run: touch database/database.sqlite + working-directory: tests-e2e/site + - name: Run Playwright tests + run: npx playwright test + working-directory: tests-e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: tests-e2e/playwright-report/ + retention-days: 30 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 06eef5024..be8f85f6f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,8 +6,7 @@ on: pull_request: branches: - main - - next - - dev + - "9.0" jobs: # Unit tests back (phpunit) @@ -18,17 +17,13 @@ jobs: include: - php: 8.2 env: - LARAVEL: 10.* - TESTBENCH: 8.* + LARAVEL: 11.* + TESTBENCH: 9.* - php: 8.3 - env: - LARAVEL: 10.* - TESTBENCH: 8.* - - php: 8.2 env: LARAVEL: 11.* TESTBENCH: 9.* - - php: 8.3 + - php: 8.4 env: LARAVEL: 11.* TESTBENCH: 9.* @@ -55,64 +50,13 @@ jobs: run: | composer require "laravel/framework:${LARAVEL}" "orchestra/testbench:${TESTBENCH}" --no-interaction --no-update composer update --prefer-stable --prefer-dist --no-interaction - - name: Execute tests (Unit and Feature tests) via PHPUnit - run: ./vendor/bin/phpunit - - # Front unit tests -# front-tests: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v2 -# -# - name: Setup Node.js -# uses: actions/setup-node@v2 -# with: -# node-version: '14' -# -# - name: Update NPM -# run: npm i -g npm@9 -# -# - name: Install front dependencies -# run: npm ci -# -# - name: Run Front tests -# run: npm run test - - # Front e2e tests - e2e-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Setup Node.js - uses: actions/setup-node@v2 - with: - node-version: '14' - - - name: Update NPM - run: npm i -g npm@9 - - - name: Install sharp dependencies - run: npm ci --production - - - name: Run E2E tests - uses: cypress-io/github-action@v2 - with: - command: npm run cy:run-ct - working-directory: tests-e2e - env: CI=true - - - uses: actions/upload-artifact@v4 - if: always() - continue-on-error: true - with: - name: e2e-cypress-screenshots - path: tests-e2e/cypress/screenshots + - name: Execute tests via Pest + run: ./vendor/bin/pest --parallel slack: needs: - laravel-tests - - e2e-tests +# - e2e-tests if: failure() && github.event_name == 'push' runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 3612c2a79..6c8a8d35b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,10 @@ node_modules .idea .DS_Store .phpunit.result.cache +.phpunit.cache .php-cs-fixer.cache /composer.lock /public /saturn -/resources/assets/dist/hot -/.phpunit.cache \ No newline at end of file +/dist/hot +/resources/assets/dist diff --git a/babel.config.js b/babel.config.js index 5b4329af5..1e02e6191 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,16 @@ +/** + * @type {import('@babel/core').TransformOptions} + */ module.exports = { + presets: [ + [ + '@babel/preset-env', + { + "useBuiltIns": "usage", + "corejs": "3.36" + } + ], + ], env: { 'test': { presets: [ diff --git a/components.json b/components.json new file mode 100644 index 000000000..aaab11cb4 --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "default", + "typescript": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "resources/css/shadcn.css", + "baseColor": "slate", + "cssVariables": true + }, + "framework": "laravel", + "aliases": { + "components": "@/components", + "utils": "@/utils/cn" + } +} diff --git a/composer.json b/composer.json index 18f6171f7..90367c5fd 100644 --- a/composer.json +++ b/composer.json @@ -12,25 +12,34 @@ } ], "require": { - "php": "8.2.*|8.3.*", - "code16/laravel-content-renderer": "^1.1.0", + "php": "8.2.*|8.3.*|8.4.*", + "ext-dom": "*", + "blade-ui-kit/blade-icons": "^1.6", + "code16/laravel-content-renderer": "^1.1", + "inertiajs/inertia-laravel": "^2.0", "intervention/image": "^3.4", "intervention/image-laravel": "^1.2", - "laravel/framework": "^10.0|^11.0", + "laravel/framework": "^11.0", + "laravel/prompts": "0.*", "league/commonmark": "^2.4", - "spatie/image-optimizer": "^1.6" + "masterminds/html5": "^2.8", + "spatie/image-optimizer": "^1.6", + "tightenco/ziggy": "^2.0" }, "require-dev": { - "brianium/paratest": "^6.3|^7.4", - "dms/phpunit-arraysubset-asserts": "^0.4|^0.5", + "brianium/paratest": "^7.0", "doctrine/dbal": "^3.5", "friendsofphp/php-cs-fixer": "^3.8", "laravel/pint": "^1.18", "mockery/mockery": "^1.5.0", - "nunomaduro/collision": "^7.0|^8.0", + "nunomaduro/collision": "^8.0", "orchestra/testbench": "^8.0|^9.0", - "phpunit/phpunit": "^9.5|^10.5", - "spatie/laravel-ray": "^1.26" + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "phpunit/phpunit": "^11.0", + "spatie/laravel-ray": "^1.26", + "spatie/laravel-typescript-transformer": "^2.3", + "spatie/typescript-transformer": "^2.2" }, "autoload": { "files": [ @@ -42,16 +51,18 @@ }, "autoload-dev": { "psr-4": { - "Code16\\Sharp\\Tests\\": "tests/" + "Code16\\Sharp\\Tests\\": "tests/", + "App\\": "vendor/orchestra/testbench-core/laravel/app" } }, "scripts": { - "test": "vendor/bin/testbench package:test --parallel" + "test": "vendor/bin/testbench package:test --parallel", + "typescript:generate": "php demo/artisan ziggy:generate --types-only; php demo/artisan typescript:transform" }, "extra": { "laravel": { "providers": [ - "Code16\\Sharp\\SharpServiceProvider" + "Code16\\Sharp\\SharpInternalServiceProvider" ] } }, diff --git a/config/config.php b/config/config.php index 77516f39e..091f61473 100644 --- a/config/config.php +++ b/config/config.php @@ -1,18 +1,14 @@ 'Sharp', - // Optional. You can here customize the URL segment in which Sharp will live. Default in "sharp". - 'custom_url_segment' => 'sharp', - // Optional. You can prevent Sharp version to be displayed in the page title. Default is true. 'display_sharp_version_in_title' => true, - // Optional. You can display a breadcrumb on all Sharp pages. Default is false. - 'display_breadcrumb' => false, + // Optional. You can display a breadcrumb on all Sharp pages. Default is true. + 'display_breadcrumb' => true, // Optional. Handle extensions. // 'extensions' => [ @@ -31,18 +27,16 @@ // 'my_entity' => \App\Sharp\Entities\MyEntity::class, ], + // Optional. Your dashboards list, as dashboardKey => \App\Sharp\Dashboards\SharpDashboard implementation + 'dashboards' => [ + // 'my_dashboard' => \App\Sharp\Dashboards\MyDashboard::class, + ], + // Optional. Your global filters list, which will be displayed in the main menu. 'global_filters' => [ // \App\Sharp\Filters\MyGlobalFilter::class ], - // Optional. Your global search implementation. - // 'search' => [ - // 'enabled' => true, - // 'placeholder' => 'Search for anything...', - // 'engine' => \App\Sharp\MySearchEngine::class, - // ], - // Required. The main menu (left bar), which may contain links to entities, dashboards // or external URLs, grouped in categories. 'menu' => null, //\App\Sharp\SharpMenu::class @@ -59,11 +53,12 @@ ], 'web' => [ \Code16\Sharp\Http\Middleware\InvalidateCache::class, + \Code16\Sharp\Http\Middleware\HandleSharpErrors::class, + \Code16\Sharp\Http\Middleware\HandleInertiaRequests::class, ], 'api' => [ - Code16\Sharp\Http\Middleware\Api\BindSharpValidationResolver::class, - Code16\Sharp\Http\Middleware\Api\HandleSharpApiErrors::class, - Code16\Sharp\Http\Middleware\Api\SetSharpLocale::class, + \Code16\Sharp\Http\Middleware\Api\BindSharpValidationResolver::class, + \Code16\Sharp\Http\Middleware\Api\HandleSharpApiErrors::class, ], ], @@ -81,6 +76,11 @@ 'transform_keep_original_image' => true, + 'max_file_size' => env('SHARP_UPLOADS_MAX_FILE_SIZE_IN_MB', 2), + + 'file_handling_queue_connection' => env('SHARP_UPLOADS_FILE_HANDLING_QUEUE_CONNECTION', 'sync'), + 'file_handling_queue' => env('SHARP_UPLOADS_FILE_HANDLING_QUEUE', 'default'), + // Optional SharpUploadModel implementation class name // 'model_class' => null, ], @@ -112,28 +112,44 @@ 'handler' => 'notification', // "notification", "totp" or a class name in custom implementation case ], - // Handle a "remember me" flag (with a checkbox on the login form) - 'suggest_remember_me' => false, + 'forgotten_password' => [ + 'enabled' => false, + 'password_broker' => null, + 'reset_password_callback' => null, + ], // Name of the attribute used to display the current user in the UI. 'display_attribute' => 'name', - // Optional additional auth check. - // 'check_handler' => \App\Sharp\Auth\MySharpCheckHandler::class, + // Optionally allow to impersonate users; by default only if enabled AND app.env is "local". + 'impersonate' => [ + 'enabled' => env('SHARP_IMPERSONATE', false), + 'handler' => null, + ], + + 'login_form' => [ + // Handle a "remember me" flag (with a checkbox on the login form) + 'suggest_remember_me' => false, + + // Display the app name on the login page. + 'display_app_name' => true, + + // Optional logo on the login page (default to theme.logo_url and to sharp logo) + // 'logo_url' => '/sharp-assets/login-logo.png', + + // Optional additional message on the login page. + // 'message_blade_path' => 'sharp/_login-page-message', + ], // Optional custom guard // 'guard' => 'sharp', ], - // 'login_page_message_blade_path' => env('SHARP_LOGIN_PAGE_MESSAGE_BLADE_PATH', 'sharp/_login-page-message'), - 'theme' => [ 'primary_color' => '#004c9b', // 'favicon_url' => '', - // 'logo_urls' => [ - // 'menu' => '/sharp-assets/menu-icon.png', - // 'login' => '/sharp-assets/login-icon.png', - // ], + // 'logo_url' => '/sharp-assets/menu-icon.png', + // 'logo_height' => '1.5rem', ], ]; diff --git a/demo/.gitignore b/demo/.gitignore index 121b471d7..acda100fc 100644 --- a/demo/.gitignore +++ b/demo/.gitignore @@ -2,6 +2,7 @@ /public/storage /public/hot /storage/*.key +/storage/clockwork /vendor /storage/clockwork node_modules diff --git a/demo/app/Http/Kernel.php b/demo/app/Http/Kernel.php index 77ab420ff..13591a65a 100644 --- a/demo/app/Http/Kernel.php +++ b/demo/app/Http/Kernel.php @@ -40,7 +40,6 @@ class Kernel extends HttpKernel ], 'api' => [ - \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, 'throttle:api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], diff --git a/demo/app/Http/Middleware/PrefillLoginWithExampleCredentials.php b/demo/app/Http/Middleware/PrefillLoginWithExampleCredentials.php new file mode 100644 index 000000000..c5449278b --- /dev/null +++ b/demo/app/Http/Middleware/PrefillLoginWithExampleCredentials.php @@ -0,0 +1,28 @@ +routeIs('code16.sharp.login')) { + Inertia::share([ + 'prefill' => [ + 'login' => 'admin@example.org', + 'password' => 'password', + ], + ]); + } + + return $next($request); + } +} diff --git a/demo/app/Models/Category.php b/demo/app/Models/Category.php index 7eff73631..757a5255a 100644 --- a/demo/app/Models/Category.php +++ b/demo/app/Models/Category.php @@ -5,12 +5,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Spatie\Translatable\HasTranslations; class Category extends Model { use HasFactory; + use HasTranslations; protected $guarded = []; + public array $translatable = [ + 'description', + ]; public function posts(): BelongsToMany { diff --git a/demo/app/Models/Post.php b/demo/app/Models/Post.php index 2a31c70ad..65d943f68 100644 --- a/demo/app/Models/Post.php +++ b/demo/app/Models/Post.php @@ -73,7 +73,12 @@ public function categories(): BelongsToMany public function isOnline(): bool { - return $this->state->value === 'online'; + return $this->state === PostState::ONLINE; + } + + public function isDraft(): bool + { + return $this->state === PostState::DRAFT; } public function getDefaultAttributesFor($attribute) diff --git a/demo/app/Models/PostAttachment.php b/demo/app/Models/PostAttachment.php index 67092c621..b8f516322 100644 --- a/demo/app/Models/PostAttachment.php +++ b/demo/app/Models/PostAttachment.php @@ -12,6 +12,9 @@ class PostAttachment extends Model use HasFactory; protected $guarded = []; + protected $casts = [ + 'is_link' => 'boolean', + ]; public function post(): BelongsTo { diff --git a/demo/app/Providers/AppServiceProvider.php b/demo/app/Providers/AppServiceProvider.php index b21a4f0ae..89144f519 100644 --- a/demo/app/Providers/AppServiceProvider.php +++ b/demo/app/Providers/AppServiceProvider.php @@ -2,24 +2,28 @@ namespace App\Providers; -use Code16\Sharp\SharpServiceProvider; -use Code16\Sharp\View\Components\Vite as SharpViteComponent; +use Code16\Sharp\Dev\SharpDevServiceProvider; +use Code16\Sharp\SharpInternalServiceProvider; +use Code16\Sharp\View\Components\ViteWrapper as SharpViteWrapperComponent; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - public function register() + public function register(): void { - $this->app->register(SharpServiceProvider::class); // $this->app->bind(SharpUploadModel::class, Media::class) - $this->app->bind(SharpViteComponent::class, function () { - return new SharpViteComponent(hotFile: base_path('../resources/assets/dist/hot')); + $this->app->register(SharpInternalServiceProvider::class); + $this->app->register(DemoSharpServiceProvider::class); + + if (class_exists(SharpDevServiceProvider::class)) { + $this->app->register(SharpDevServiceProvider::class); + } + + $this->app->bind(SharpViteWrapperComponent::class, function () { + return new SharpViteWrapperComponent(hotFile: base_path('../dist/hot')); }); } - public function boot() - { - // - } + public function boot(): void {} } diff --git a/demo/app/Providers/DemoSharpServiceProvider.php b/demo/app/Providers/DemoSharpServiceProvider.php new file mode 100644 index 000000000..464f0919e --- /dev/null +++ b/demo/app/Providers/DemoSharpServiceProvider.php @@ -0,0 +1,55 @@ +setName('Demo project') + ->addEntity('posts', PostEntity::class) + ->addEntity('blocks', PostBlockEntity::class) + ->addEntity('categories', CategoryEntity::class) + ->addEntity('authors', AuthorEntity::class) + ->addEntity('profile', ProfileEntity::class) + ->addEntity('dashboard', DemoDashboardEntity::class) + ->addEntity('test', TestEntity::class) + ->addGlobalFilter(DummyGlobalFilter::class) + ->configureUploadsThumbnailCreation(uploadModelClass: Media::class) + ->setSharpMenu(SharpMenu::class) + ->setThemeColor('#004c9b') + ->setThemeLogo(logoUrl: '/img/sharp/logo.svg', logoHeight: '1rem', faviconUrl: '/img/sharp/favicon-32x32.png') +// ->redirectLoginToUrl('/my-login') + ->enableImpersonation() + ->enableForgottenPassword() + ->setAuthCustomGuard('web') + ->setLoginAttributes('email', 'password') + ->setUserDisplayAttribute('name') + ->enable2faCustom(Demo2faNotificationHandler::class) + ->enableLoginRateLimiting(maxAttempts: 3) + ->suggestRememberMeOnLoginForm() + ->appendMessageOnLoginForm('sharp._login-page-message') + ->enableGlobalSearch(AppSearchEngine::class, 'Search for posts or authors...') + ->appendToMiddlewareWebGroup(PrefillLoginWithExampleCredentials::class) + ->loadViteAssets([ + 'resources/css/sharp-extension.css', + ]); + } +} diff --git a/demo/app/Sharp/Authors/AuthorList.php b/demo/app/Sharp/Authors/AuthorList.php index b8a5c7fb1..16ed2b25b 100644 --- a/demo/app/Sharp/Authors/AuthorList.php +++ b/demo/app/Sharp/Authors/AuthorList.php @@ -19,28 +19,25 @@ protected function buildList(EntityListFieldsContainer $fields): void $fields ->addField( EntityListField::make('avatar') - ->setWidth(1) - ->setWidthOnSmallScreens(2) + ->setWidth(.1) ->setLabel(''), ) ->addField( EntityListField::make('name') - ->setWidth(3) - ->setWidthOnSmallScreens(5) + ->setWidth(.3) ->setLabel('Name') ->setSortable(), ) ->addField( EntityListField::make('email') - ->setWidth(4) + ->setWidth(.3) ->hideOnSmallScreens() ->setLabel('Email') ->setSortable(), ) ->addField( EntityListField::make('role') - ->setWidth(4) - ->setWidthOnSmallScreens(5) + ->setWidth(.3) ->setLabel('Role'), ); } diff --git a/demo/app/Sharp/Authors/Commands/InviteUserCommand.php b/demo/app/Sharp/Authors/Commands/InviteUserCommand.php index 61caeceba..f47a33aa4 100644 --- a/demo/app/Sharp/Authors/Commands/InviteUserCommand.php +++ b/demo/app/Sharp/Authors/Commands/InviteUserCommand.php @@ -16,10 +16,9 @@ public function label(): ?string public function buildCommandConfig(): void { $this->configureFormModalTitle('Invite a new user as author') - ->configurePageAlert( - '
This user has configured a two-factor authentication (see documentation).
+Code was set to 123456 for this demo.
+Please enter the 6-digit code
HTML; } } diff --git a/demo/app/Sharp/DummyGlobalFilter.php b/demo/app/Sharp/DummyGlobalFilter.php index 11406edc1..43e5a7bfc 100644 --- a/demo/app/Sharp/DummyGlobalFilter.php +++ b/demo/app/Sharp/DummyGlobalFilter.php @@ -18,4 +18,9 @@ public function defaultValue(): mixed { return '1'; } + + public function authorize(): bool + { + return auth()->id() === 1; + } } diff --git a/demo/app/Sharp/Entities/CategoryEntity.php b/demo/app/Sharp/Entities/CategoryEntity.php index 6ed030ad6..2d79fd113 100644 --- a/demo/app/Sharp/Entities/CategoryEntity.php +++ b/demo/app/Sharp/Entities/CategoryEntity.php @@ -9,6 +9,7 @@ class CategoryEntity extends SharpEntity { + protected string $label = 'Category'; protected ?string $list = CategoryList::class; protected ?string $show = CategoryShow::class; protected ?string $form = CategoryForm::class; diff --git a/demo/app/Sharp/Entities/PostBlockEntity.php b/demo/app/Sharp/Entities/PostBlockEntity.php index 19388ca1c..2d5c3891a 100644 --- a/demo/app/Sharp/Entities/PostBlockEntity.php +++ b/demo/app/Sharp/Entities/PostBlockEntity.php @@ -18,9 +18,9 @@ class PostBlockEntity extends SharpEntity public function getMultiforms(): array { return [ - 'text' => [PostBlockTextForm::class, 'Text'], - 'visuals' => [PostBlockVisualsForm::class, 'Visuals'], - 'video' => [PostBlockVideoForm::class, 'Video'], + 'text' => [PostBlockTextForm::class, 'Text block'], + 'visuals' => [PostBlockVisualsForm::class, 'Visuals block'], + 'video' => [PostBlockVideoForm::class, 'Video block'], ]; } } diff --git a/demo/app/Sharp/Entities/PostEntity.php b/demo/app/Sharp/Entities/PostEntity.php index 0ef73f480..5995bfc97 100644 --- a/demo/app/Sharp/Entities/PostEntity.php +++ b/demo/app/Sharp/Entities/PostEntity.php @@ -14,4 +14,5 @@ class PostEntity extends SharpEntity protected ?string $show = PostShow::class; protected ?string $form = PostForm::class; protected ?string $policy = PostPolicy::class; + protected string $label = 'Post'; } diff --git a/demo/app/Sharp/Posts/Blocks/AbstractPostBlockForm.php b/demo/app/Sharp/Posts/Blocks/AbstractPostBlockForm.php index d678cc917..0d5c19924 100644 --- a/demo/app/Sharp/Posts/Blocks/AbstractPostBlockForm.php +++ b/demo/app/Sharp/Posts/Blocks/AbstractPostBlockForm.php @@ -24,7 +24,12 @@ public function buildFormFields(FieldsContainer $formFields): void $formFields ->addField( SharpFormHtmlField::make('type') - ->setInlineTemplate('Post block type: {{name}}fezfjklez fezjkflezjfkez fezjkflezjfklezjkflezj
', @@ -339,7 +379,19 @@ protected function findSingle() ]; } - return $this->transform($rawData); + return $this + ->setCustomTransformer('upload', (new SharpUploadModelFormAttributeTransformer())->dynamicInstance()) + ->setCustomTransformer('html', fn () => [ + 'name' => fake()->name, + ]) + ->transform($rawData); + } + + public function rules(): array + { + return [ + // 'date' => 'required|before_or_equal:'.date('Y-m-d'), + ]; } protected function updateSingle(array $data) @@ -352,20 +404,19 @@ public function getDataLocalizations(): array return ['fr', 'en']; } - protected function options(bool $localized = false): array + protected function options(): array { - if (! $localized) { - return [ - '1' => 'Option one', - '2' => 'Option two', - '3' => 'Option three', - ]; - } - return [ - '1' => ['en' => 'Option one', 'fr' => 'Option un'], - '2' => ['en' => 'Option two', 'fr' => 'Option deux'], - '3' => ['en' => 'Option three', 'fr' => 'Option trois'], + '1' => 'Option one', + '2' => 'Option two', + '3' => 'Option three', + '4' => 'Option four', + '5' => 'Option five', + '6' => 'Option six', + '7' => 'Option seven', + '8' => 'Option eight', + '9' => 'Option nine', + '10' => 'Option ten', ]; } } diff --git a/demo/app/Sharp/TestForm/TestShow.php b/demo/app/Sharp/TestForm/TestShow.php index 12d01e982..80ec8893a 100644 --- a/demo/app/Sharp/TestForm/TestShow.php +++ b/demo/app/Sharp/TestForm/TestShow.php @@ -20,15 +20,15 @@ public function buildShowLayout(ShowLayout $showLayout): void { $showLayout->addSection('', function (ShowLayoutSection $section) { $section->addColumn(12, function (ShowLayoutColumn $column) { - $column->withSingleField('message'); + $column->withField('message'); }); }); } public function findSingle(): array { - return [ + return $this->transform([ 'message' => '{{ $code }}
diff --git a/demo/resources/views/components/related-post.blade.php b/demo/resources/views/components/related-post.blade.php
index 5e97bd5f8..ec84ae6a9 100644
--- a/demo/resources/views/components/related-post.blade.php
+++ b/demo/resources/views/components/related-post.blade.php
@@ -3,15 +3,13 @@
])
@if($post = is_string($post) ? \App\Models\Post::find($post) : $post)
- + Congrats 🥳 to {{ $author->name }}, + for the + {!! $post->categories->map(fn ($category) => \Code16\Sharp\Utils\Links\LinkToShowPage::make('categories', $category->id)->renderAsText('#'.$category->name))->implode(' / ') !!} + post: +
+ @if($post) + + @endif +@endif diff --git a/demo/routes/api.php b/demo/routes/api.php index 457a02b14..b3d9bbc7f 100644 --- a/demo/routes/api.php +++ b/demo/routes/api.php @@ -1,19 +1 @@ get('/admin/users', function (Request $request) { - $users = User::orderBy('name'); - - foreach (explode(' ', trim($request->query('query'))) as $word) { - $users->where(function (Builder $query) use ($word) { - $query->orWhere('name', 'like', "%$word%") - ->orWhere('email', 'like', "%$word%"); - }); - } - - return $users->limit(10)->get(); -}); diff --git a/demo/routes/web.php b/demo/routes/web.php index fd6e5c421..5d2399781 100644 --- a/demo/routes/web.php +++ b/demo/routes/web.php @@ -1,5 +1,8 @@ $post]); }); + +Route::get('/admin/users', function (Request $request) { + $users = User::orderBy('name'); + + foreach (explode(' ', trim($request->query('query'))) as $word) { + $users->where(function (Builder $query) use ($word) { + $query->orWhere('name', 'like', "%$word%") + ->orWhere('email', 'like', "%$word%"); + }); + } + + return $users->limit(10)->get(); +})->name('sharp.autocompletes.users.index'); diff --git a/demo/tailwind.config.js b/demo/tailwind.config.js new file mode 100644 index 000000000..c8e323939 --- /dev/null +++ b/demo/tailwind.config.js @@ -0,0 +1,11 @@ +import typography from '@tailwindcss/typography'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './resources/views/**/*.blade.php', + ], + plugins: [ + typography, + ], +} diff --git a/demo/tailwind.sharp.config.js b/demo/tailwind.sharp.config.js new file mode 100644 index 000000000..ae8d06783 --- /dev/null +++ b/demo/tailwind.sharp.config.js @@ -0,0 +1,9 @@ + +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './resources/views/sharp/**/*.blade.php', + ], + plugins: [ + ], +}; diff --git a/demo/tests/Feature/PostSharpFormTest.php b/demo/tests/Feature/PostSharpFormTest.php index c1f181211..1895ef067 100644 --- a/demo/tests/Feature/PostSharpFormTest.php +++ b/demo/tests/Feature/PostSharpFormTest.php @@ -5,53 +5,37 @@ use App\Models\Post; use App\Models\User; use App\Sharp\Posts\Commands\PreviewPostCommand; -use Code16\Sharp\Form\Fields\SharpFormDateField; use Code16\Sharp\Utils\Testing\SharpAssertions; -use Illuminate\Foundation\Testing\DatabaseMigrations; +use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Tests\TestCase; class PostSharpFormTest extends TestCase { - use DatabaseMigrations; + use LazilyRefreshDatabase; use SharpAssertions; - /** @test */ - public function we_can_get_a_valid_post_update_form() + protected function setUp(): void { - $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); - $post = Post::factory()->create(); + parent::setUp(); - $this->getSharpForm('posts', $post->id) - ->assertSharpFormHasFieldOfType('published_at', SharpFormDateField::class) - ->assertSharpFormHasFields([ - 'title', 'content', 'categories', 'cover', - ]); + $this->withoutVite(); } /** @test */ - public function we_can_preview_a_post_through_command() + public function we_can_edit_a_post() { $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); $post = Post::factory()->create(); $this - ->callSharpInstanceCommandFromList( - 'posts', - $post->id, - PreviewPostCommand::class, - ) + ->withSharpCurrentBreadcrumb(['list', 'posts']) + ->getSharpForm('posts', $post->id) ->assertOk(); - } - - /** @test */ - public function we_can_get_a_valid_post_create_form() - { - $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); - $this->getSharpForm('posts') - ->assertSharpFormHasFields([ - 'title', 'content', 'categories', 'cover', - ]); + $this + ->withSharpCurrentBreadcrumb(['list', 'posts']) + ->getSharpForm('posts') + ->assertOk(); } /** @test */ @@ -76,7 +60,7 @@ public function we_can_update_a_post() ], ), ) - ->assertOk(); + ->assertSessionHasNoErrors(); $this->assertDatabaseHas('posts', [ 'id' => $post->id, @@ -84,23 +68,133 @@ public function we_can_update_a_post() ]); } + /** @test */ + public function we_can_not_update_a_post_with_invalid_data() + { + $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); + $post = Post::factory()->create(); + + $this + ->updateSharpForm( + 'posts', + $post->id, + array_merge( + $post->toArray(), + [ + 'title' => [ + 'fr' => 'updated', + 'en' => null, + ], + ], + ), + ) + ->assertSessionHasErrors(['title.en']); + } + + /** @test */ + public function we_can_store_a_new_post() + { + $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); + + $this + ->storeSharpForm( + 'posts', + [ + 'title' => [ + 'fr' => 'titre', + 'en' => 'title', + ], + 'published_at' => now()->setTime(10, 30)->format('Y-m-d H:i:s'), + 'content' => [ + 'text' => [ + 'fr' => 'nouveau', + 'en' => 'new', + ], + ], + ], + ) + ->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('posts', [ + 'title' => json_encode(['en' => 'title', 'fr' => 'titre']), + 'published_at' => now()->setTime(10, 30)->format('Y-m-d H:i:s'), + 'content' => json_encode(['en' => 'new', 'fr' => 'nouveau']), + ]); + } + + /** @test */ + public function we_can_delete_a_post() + { + $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); + $post1 = Post::factory()->create(); + $post2 = Post::factory()->create(); + + $this + ->deleteFromSharpShow('posts', $post1->id) + ->assertRedirect(); + + $this->assertDatabaseMissing('posts', ['id' => $post1->id]); + $this->assertDatabaseHas('posts', ['id' => $post2->id]); + + $this + ->deleteFromSharpList('posts', $post2->id) + ->assertOk(); + + $this->assertDatabaseMissing('posts', ['id' => $post2->id]); + } + /** @test */ public function as_an_editor_we_are_not_authorize_to_update_a_post_of_another_editor() { $this->loginAsSharpUser(User::factory()->create(['role' => 'editor'])); - $post = Post::factory() + $publishedPost = Post::factory() ->for(User::factory(), 'author') ->create(); - $this->getSharpForm('posts', $post->id) - ->assertSharpHasNotAuthorization('update'); + $this + ->withSharpCurrentBreadcrumb(['list', 'posts']) + ->getSharpShow('posts', $publishedPost->id) + ->assertOk(); + + $this + ->withSharpCurrentBreadcrumb( + ['list', 'posts'], + ['show', 'posts', $publishedPost->id], + ) + ->getSharpForm('posts', $publishedPost->id) + ->assertForbidden(); } - protected function setUp(): void + /** @test */ + public function as_an_editor_we_are_not_authorize_to_view_an_unpublished_post_of_another_editor() { - parent::setUp(); + $this->loginAsSharpUser(User::factory()->create(['role' => 'editor'])); + + $publishedPost = Post::factory() + ->for(User::factory(), 'author') + ->create([ + 'state' => 'draft', + ]); + + $this + ->withSharpCurrentBreadcrumb(['list', 'posts']) + ->getSharpShow('posts', $publishedPost->id) + ->assertForbidden(); + } - $this->initSharpAssertions(); + /** @test */ + public function we_can_preview_a_post_through_command() + { + $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); + $post = Post::factory()->create(); + + $this + ->callSharpInstanceCommandFromList( + 'posts', + $post->id, + PreviewPostCommand::class, + ) + ->assertOk(); } } diff --git a/demo/vite.config.js b/demo/vite.config.js index 125c3c782..783fc4899 100644 --- a/demo/vite.config.js +++ b/demo/vite.config.js @@ -4,6 +4,7 @@ import laravel from 'laravel-vite-plugin'; export default defineConfig({ plugins: [ laravel([ + 'resources/css/app.css', 'resources/js/sharp-plugin.js', 'resources/css/sharp-extension.css', ]), diff --git a/dist/assets/CardDescription.vue_vue_type_script_setup_true_lang-BVFKuiG8.js b/dist/assets/CardDescription.vue_vue_type_script_setup_true_lang-BVFKuiG8.js new file mode 100644 index 000000000..aeff77ac0 --- /dev/null +++ b/dist/assets/CardDescription.vue_vue_type_script_setup_true_lang-BVFKuiG8.js @@ -0,0 +1 @@ +import{d as o,o as t,A as r,q as n,U as c,u as l,z as p}from"./sharp-DDNPuC1w.js";const u=o({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(a,m)=>(t(),r("div",{class:c(l(p)("text-sm text-muted-foreground",e.class))},[n(a.$slots,"default")],2))}});export{u as _}; diff --git a/dist/assets/Dashboard-D5MXfoDd.js b/dist/assets/Dashboard-D5MXfoDd.js new file mode 100644 index 000000000..d4d72e47c --- /dev/null +++ b/dist/assets/Dashboard-D5MXfoDd.js @@ -0,0 +1,838 @@ +import{d as We,u as _,V as hr,o as W,c as he,w as D,q as ma,v as Ti,W as cr,A as le,Q as ri,a as U,O as ni,j as ye,b as xe,t as oe,F as ze,i as me,R as oi,T as li,H as qe,_ as Xe,X as zi,Y as Ji,Z as dr,$ as ur,a0 as gr,a1 as pr,a2 as cs,a3 as Ki,a4 as fr,a5 as xr,a6 as mr,a7 as br,a8 as vr,a9 as ds,aa as yr,ab as wr,ac as kr,ad as Ar,ae as Cr,af as Sr,ag as Lr,ah as us,ai as Jt,aj as Mr,y as ba,ak as Pr,al as Ir,am as Tr,an as zr,ao as Xr,ap as _r,aq as Rr,ar as ut,as as Er,at as Or,U as Qi,au as gs,av as Yr,aw as Hr,r as Lt,ax as Fr,ay as Dr,az as Nr,J as Wr,aA as Br,k as Ge,aB as va,aC as ya,n as wa,aD as ka,aE as Aa,aF as Ca,aG as Wt,aH as Sa,aI as La,aJ as Ma,aK as Pa,aL as Gr}from"./sharp-DDNPuC1w.js";import{_ as jr,u as Vr,a as Ia,F as Ta,b as za,c as Xa,d as Ur,p as di,e as Bt}from"./DropdownChevronDown.vue_vue_type_script_setup_true_lang-DZQFkxFU.js";import{_ as qr}from"./Title.vue_vue_type_script_setup_true_lang-MxfiLW2l.js";import{_ as $r}from"./PageBreadcrumb.vue_vue_type_script_setup_true_lang-D6ipke--.js";import"./TemplateRenderer.vue_vue_type_script_setup_true_lang-td6RXtbk.js";const Zr=["href"],ea=We({__name:"MaybeInertiaLink",props:{href:{}},setup(o){return(e,t)=>_(hr)(e.href)?(W(),he(_(cr),Ti({key:0,href:e.href},e.$attrs),{default:D(()=>[ma(e.$slots,"default")]),_:3},16,["href"])):(W(),le("a",Ti({key:1,href:e.href},e.$attrs),[ma(e.$slots,"default")],16,Zr))}}),Jr={class:"text-2xl font-bold"},Kr={key:0,class:"text-xs text-muted-foreground"},Qr=We({__name:"Figure",props:{widget:{},value:{}},setup(o){return(e,t)=>(W(),he(_(li),{class:"relative"},{default:D(()=>[e.widget.title||e.value.data.evolution?(W(),he(_(ri),{key:0,class:"flex flex-row items-center gap-2 pb-2"},{default:D(()=>[U(_(ni),{class:"text-sm tracking-tight font-medium"},{default:D(()=>[e.widget.link?(W(),he(ea,{key:0,class:"hover:underline",href:e.widget.link},{default:D(()=>[t[0]||(t[0]=ye("span",{class:"absolute inset-0"},null,-1)),xe(" "+oe(e.widget.title),1)]),_:1},8,["href"])):(W(),le(ze,{key:1},[xe(oe(e.widget.title),1)],64))]),_:1})]),_:1})):me("",!0),U(_(oi),null,{default:D(()=>[ye("div",Jr,[xe(oe(e.value.data.figure)+" ",1),e.value.data.unit?(W(),le(ze,{key:0},[xe(oe(e.value.data.unit),1)],64)):me("",!0)]),e.value.data.evolution?(W(),le("p",Kr,oe(e.value.data.evolution),1)):me("",!0)]),_:1})]),_:1}))}}),en={class:"-my-2 divide-y"},tn={class:"group/item isolate relative flex items-center py-4 gap-x-4"},an={key:0,class:"absolute inset-0 -inset-x-2 -z-10 transition-colors group-hover/item:bg-muted/50"},sn={class:"flex-1"},rn=["innerHTML"],nn=We({__name:"OrderedList",props:{widget:{},value:{}},setup(o){return(e,t)=>(W(),he(_(li),null,{default:D(()=>[e.widget.title?(W(),he(_(ri),{key:0},{default:D(()=>[U(_(ni),{class:"text-base/none font-semibold tracking-tight"},{default:D(()=>[xe(oe(e.widget.title),1)]),_:1})]),_:1})):me("",!0),U(_(oi),null,{default:D(()=>[ye("div",en,[(W(!0),le(ze,null,qe(e.value.data,i=>(W(),le("div",tn,[i.url?(W(),le("div",an)):me("",!0),ye("div",sn,[ye("div",{class:"content content-sm text-sm",innerHTML:i.label},null,8,rn),i.url?(W(),he(ea,{key:0,href:i.url,"aria-label":_(Xe)("sharp::dashboard.widget.link_label")},{default:D(()=>t[0]||(t[0]=[ye("span",{class:"absolute inset-0"},null,-1)])),_:2},1032,["href","aria-label"])):me("",!0)]),i.count!=null?(W(),he(_(zi),{key:1,variant:"secondary"},{default:D(()=>[xe(oe(i.count),1)]),_:2},1024)):me("",!0)]))),256))])]),_:1})]),_:1}))}}),on=We({__name:"Panel",props:{widget:{},value:{}},setup(o){return(e,t)=>(W(),he(_(li),{class:"relative"},{default:D(()=>[e.widget.title?(W(),he(_(ri),{key:0},{default:D(()=>[U(_(ni),{class:"text-base/none font-semibold tracking-tight"},{default:D(()=>[e.widget.link?(W(),he(ea,{key:0,class:"hover:underline",href:e.widget.link},{default:D(()=>[t[0]||(t[0]=ye("span",{class:"absolute inset-0"},null,-1)),xe(" "+oe(e.widget.title),1)]),_:1},8,["href"])):(W(),le(ze,{key:1},[xe(oe(e.widget.title),1)],64))]),_:1})]),_:1})):me("",!0),U(_(oi),null,{default:D(()=>[U(jr,{class:"content-sm text-sm",html:e.value.html},null,8,["html"])]),_:1})]),_:1}))}}),ln="en",hn={months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],toolbar:{exportToSVG:"Download SVG",exportToPNG:"Download PNG",exportToCSV:"Download CSV",menu:"Menu",selection:"Selection",selectionZoom:"Selection Zoom",zoomIn:"Zoom In",zoomOut:"Zoom Out",pan:"Panning",reset:"Reset Zoom"}},cn={name:ln,options:hn},dn="fr",un={months:["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],shortMonths:["janv.","févr.","mars","avr.","mai","juin","juill.","août","sept.","oct.","nov.","déc."],days:["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"],shortDays:["dim.","lun.","mar.","mer.","jeu.","ven.","sam."],toolbar:{exportToSVG:"Télécharger au format SVG",exportToPNG:"Télécharger au format PNG",exportToCSV:"Télécharger au format CSV",menu:"Menu",selection:"Sélection",selectionZoom:"Sélection et zoom",zoomIn:"Zoomer",zoomOut:"Dézoomer",pan:"Navigation",reset:"Réinitialiser le zoom"}},gn={name:dn,options:un},pn="ru",fn={months:["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"],shortMonths:["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"],days:["Воскресенье","Понедельник","Вторник","Среда","Четверг","Пятница","Суббота"],shortDays:["Вс","Пн","Вт","Ср","Чт","Пт","Сб"],toolbar:{exportToSVG:"Сохранить SVG",exportToPNG:"Сохранить PNG",exportToCSV:"Сохранить CSV",menu:"Меню",selection:"Выбор",selectionZoom:"Выбор с увеличением",zoomIn:"Увеличить",zoomOut:"Уменьшить",pan:"Перемещение",reset:"Сбросить увеличение"}},xn={name:pn,options:fn},mn="es",bn={months:["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"],shortMonths:["Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"],days:["Domingo","Lunes","Martes","Miércoles","Jueves","Viernes","Sábado"],shortDays:["Dom","Lun","Mar","Mie","Jue","Vie","Sab"],toolbar:{exportToSVG:"Descargar SVG",exportToPNG:"Descargar PNG",exportToCSV:"Descargar CSV",menu:"Menu",selection:"Seleccionar",selectionZoom:"Seleccionar Zoom",zoomIn:"Aumentar",zoomOut:"Disminuir",pan:"Navegación",reset:"Reiniciar Zoom"}},vn={name:mn,options:bn},yn="de",wn={months:["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],shortMonths:["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],days:["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"],shortDays:["So","Mo","Di","Mi","Do","Fr","Sa"],toolbar:{exportToSVG:"SVG speichern",exportToPNG:"PNG speichern",exportToCSV:"CSV speichern",menu:"Menü",selection:"Auswahl",selectionZoom:"Auswahl vergrößern",zoomIn:"Vergrößern",zoomOut:"Verkleinern",pan:"Verschieben",reset:"Zoom zurücksetzen"}},kn={name:yn,options:wn};var An=Ji;function Cn(){this.__data__=new An,this.size=0}var Sn=Cn;function Ln(o){var e=this.__data__,t=e.delete(o);return this.size=e.size,t}var Mn=Ln;function Pn(o){return this.__data__.get(o)}var In=Pn;function Tn(o){return this.__data__.has(o)}var zn=Tn,Xn=Ji,_n=dr,Rn=ur,En=200;function On(o,e){var t=this.__data__;if(t instanceof Xn){var i=t.__data__;if(!_n||i.length- {{ value.data.figure }} - - {{ value.data.unit }} - -
- -