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..1ada53b44 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,64 @@ +name: E2E Tests (playwright) +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 + - name: Install Composer dependencies + run: composer install + - name: Build assets + run: npm run build + - name: Install dependencies + run: npm ci + working-directory: tests-e2e + - name: Install Playwright Browsers + run: npx playwright install chromium --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 + php artisan snapshot:load e2e-seed + working-directory: tests-e2e/site + - name: Run server + run: php artisan serve --host=127.0.0.1 --port=8080 & + working-directory: tests-e2e/site + - name: Run Playwright tests + run: npx playwright test + working-directory: tests-e2e + env: + CI: true + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: report + path: | + tests-e2e/playwright-report/ + tests-e2e/site/storage/logs/ + retention-days: 30 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 7c23e986b..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Tests -on: - push: - branches: - - main - pull_request: - branches: - - main - - next - - dev -jobs: - - # Unit tests back (phpunit) - laravel-tests: - runs-on: ubuntu-latest - strategy: - matrix: - include: - - php: 8.2 - env: - LARAVEL: 10.* - TESTBENCH: 8.* - - php: 8.3 - env: - LARAVEL: 10.* - TESTBENCH: 8.* - - php: 8.2 - env: - LARAVEL: 11.* - TESTBENCH: 9.* - - php: 8.3 - env: - LARAVEL: 11.* - TESTBENCH: 9.* - - php: 8.4 - env: - LARAVEL: 11.* - TESTBENCH: 9.* - env: ${{ matrix.env }} - steps: - - uses: actions/checkout@v2 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: mbstring, dom, fileinfo, mysql - - name: Setup locales - run: sudo locale-gen fr_FR.UTF-8 - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - name: Cache composer dependencies - uses: actions/cache@v1 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- - - name: Install Composer dependencies - 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 - - slack: - needs: - - laravel-tests - - e2e-tests - if: failure() && github.event_name == 'push' - runs-on: ubuntu-latest - steps: - - uses: 8398a7/action-slack@v2.4.0 - with: - status: failure - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..be8f85f6f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,68 @@ +name: Tests +on: + push: + branches: + - main + pull_request: + branches: + - main + - "9.0" +jobs: + + # Unit tests back (phpunit) + laravel-tests: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - php: 8.2 + env: + LARAVEL: 11.* + TESTBENCH: 9.* + - php: 8.3 + env: + LARAVEL: 11.* + TESTBENCH: 9.* + - php: 8.4 + env: + LARAVEL: 11.* + TESTBENCH: 9.* + env: ${{ matrix.env }} + steps: + - uses: actions/checkout@v2 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, dom, fileinfo, mysql + - name: Setup locales + run: sudo locale-gen fr_FR.UTF-8 + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache composer dependencies + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install Composer dependencies + run: | + composer require "laravel/framework:${LARAVEL}" "orchestra/testbench:${TESTBENCH}" --no-interaction --no-update + composer update --prefer-stable --prefer-dist --no-interaction + - name: Execute tests via Pest + run: ./vendor/bin/pest --parallel + + slack: + needs: + - laravel-tests +# - e2e-tests + if: failure() && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: 8398a7/action-slack@v2.4.0 + with: + status: failure + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 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 435cabe30..6f72cf036 100644 --- a/composer.json +++ b/composer.json @@ -13,24 +13,33 @@ ], "require": { "php": "8.2.*|8.3.*|8.4.*", - "code16/laravel-content-renderer": "^1.1.0", + "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.3", "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..4a26d2535 --- /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(view('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/AppSearchEngine.php b/demo/app/Sharp/AppSearchEngine.php index 4953be77c..e5d87875b 100644 --- a/demo/app/Sharp/AppSearchEngine.php +++ b/demo/app/Sharp/AppSearchEngine.php @@ -76,4 +76,9 @@ private function searchForAuthors(array $terms): void ); }); } + + public function authorize(): bool + { + return true; + } } 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/resources/views/vendor/sharp/partials/plugin-script.blade.php b/demo/resources/views/vendor/sharp/partials/plugin-script.blade.php deleted file mode 100644 index 6ea4a9cc8..000000000 --- a/demo/resources/views/vendor/sharp/partials/plugin-script.blade.php +++ /dev/null @@ -1,2 +0,0 @@ - -@vite('resources/js/sharp-plugin.js') 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..fe71fc62e 100644 --- a/demo/routes/web.php +++ b/demo/routes/web.php @@ -1,5 +1,8 @@ $post]); + return view('pages.post', ['post' => $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..9e14a14c2 100644 --- a/demo/tests/Feature/PostSharpFormTest.php +++ b/demo/tests/Feature/PostSharpFormTest.php @@ -5,53 +5,41 @@ 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, + ->withSharpBreadcrumb( + fn ($builder) => $builder->appendEntityList('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 + ->withSharpBreadcrumb( + fn ($builder) => $builder->appendEntityList('posts') + ) + ->getSharpForm('posts') + ->assertOk(); } /** @test */ @@ -76,7 +64,7 @@ public function we_can_update_a_post() ], ), ) - ->assertOk(); + ->assertSessionHasNoErrors(); $this->assertDatabaseHas('posts', [ 'id' => $post->id, @@ -84,23 +72,138 @@ 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 + ->withSharpBreadcrumb( + fn ($builder) => $builder->appendEntityList('posts') + ) + ->getSharpShow('posts', $publishedPost->id) + ->assertOk(); + + $this + ->withSharpBreadcrumb( + fn ($builder) => $builder + ->appendEntityList('posts') + ->appendShowPage('posts', $publishedPost->id), + ) + ->getSharpForm('posts', $publishedPost->id) + ->assertForbidden(); + } + + /** @test */ + public function as_an_editor_we_are_not_authorize_to_view_an_unpublished_post_of_another_editor() + { + $this->loginAsSharpUser(User::factory()->create(['role' => 'editor'])); + + $publishedPost = Post::factory() + ->for(User::factory(), 'author') + ->create([ + 'state' => 'draft', + ]); + + $this + ->withSharpBreadcrumb( + fn ($builder) => $builder->appendEntityList('posts') + ) + ->getSharpShow('posts', $publishedPost->id) + ->assertForbidden(); } - protected function setUp(): void + /** @test */ + public function we_can_preview_a_post_through_command() { - parent::setUp(); + $this->loginAsSharpUser(User::factory()->create(['role' => 'admin'])); + $post = Post::factory()->create(); - $this->initSharpAssertions(); + $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-B8DPS8LM.js b/dist/assets/CardDescription.vue_vue_type_script_setup_true_lang-B8DPS8LM.js new file mode 100644 index 000000000..644ce56dd --- /dev/null +++ b/dist/assets/CardDescription.vue_vue_type_script_setup_true_lang-B8DPS8LM.js @@ -0,0 +1 @@ +import{d as o,o as t,A as r,q as n,V as c,u as l,z as p}from"./sharp-ClLWbqcq.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-Dqj2JRKb.js b/dist/assets/Dashboard-Dqj2JRKb.js new file mode 100644 index 000000000..3f728f98b --- /dev/null +++ b/dist/assets/Dashboard-Dqj2JRKb.js @@ -0,0 +1,838 @@ +import{d as We,u as X,W as hr,o as W,c as oe,w as D,q as ma,v as Xi,X as cr,A as de,R as Tt,a as U,Q as li,j as be,b as xe,t as se,F as Xe,i as fe,S as hi,U as ci,H as qe,_ as ze,Y as _i,Z as dr,V as Yt,$ as Qi,a0 as ur,a1 as gr,a2 as pr,a3 as fr,a4 as cs,a5 as ea,a6 as xr,a7 as mr,a8 as br,a9 as vr,aa as yr,ab as ds,ac as wr,ad as kr,ae as Ar,af as Cr,ag as Sr,ah as Lr,ai as Mr,aj as us,ak as Qt,al as Pr,y as ba,am as Ir,an as Tr,ao as zr,ap as Xr,aq as _r,ar as Rr,as as Er,at as ut,au as Or,av as Yr,aw as gs,ax as Hr,ay as Fr,r as Lt,az as Dr,aA as Nr,aB as Wr,J as Br,aC as Gr,k as Ge,aD as va,aE as ya,n as wa,aF as ka,aG as Aa,aH as Ca,aI as Gt,aJ as Sa,aK as La,aL as Ma,aM as Pa,aN as jr}from"./sharp-ClLWbqcq.js";import{u as Vr,_ as Ia,F as Ta,a as za,b as Xa,c as Ur,p as gi,d as jt}from"./DropdownChevronDown.vue_vue_type_script_setup_true_lang-B03Egr1o.js";import{_ as qr}from"./Title.vue_vue_type_script_setup_true_lang-CavU8pqV.js";import{_ as $r}from"./PageBreadcrumb.vue_vue_type_script_setup_true_lang-sEIuBwGj.js";const Zr=["href"],ei=We({__name:"MaybeInertiaLink",props:{href:{}},setup(o){return(e,t)=>X(hr)(e.href)?(W(),oe(X(cr),Xi({key:0,href:e.href},e.$attrs),{default:D(()=>[ma(e.$slots,"default")]),_:3},16,["href"])):(W(),de("a",Xi({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(),oe(X(ci),{class:"relative"},{default:D(()=>[e.widget.title||e.value.data.evolution?(W(),oe(X(Tt),{key:0,class:"flex flex-row items-center gap-2 pb-2"},{default:D(()=>[U(X(li),{class:"text-sm tracking-tight font-medium"},{default:D(()=>[e.widget.link?(W(),oe(ei,{key:0,class:"hover:underline",href:e.widget.link},{default:D(()=>[t[0]||(t[0]=be("span",{class:"absolute inset-0"},null,-1)),xe(" "+se(e.widget.title),1)]),_:1},8,["href"])):(W(),de(Xe,{key:1},[xe(se(e.widget.title),1)],64))]),_:1})]),_:1})):fe("",!0),U(X(hi),null,{default:D(()=>[be("div",Jr,[xe(se(e.value.data.figure)+" ",1),e.value.data.unit?(W(),de(Xe,{key:0},[xe(se(e.value.data.unit),1)],64)):fe("",!0)]),e.value.data.evolution?(W(),de("p",Kr,se(e.value.data.evolution),1)):fe("",!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(),oe(X(ci),null,{default:D(()=>[e.widget.title?(W(),oe(X(Tt),{key:0},{default:D(()=>[U(X(li),{class:"text-base/none font-semibold tracking-tight"},{default:D(()=>[xe(se(e.widget.title),1)]),_:1})]),_:1})):fe("",!0),U(X(hi),null,{default:D(()=>[be("div",en,[(W(!0),de(Xe,null,qe(e.value.data,i=>(W(),de("div",tn,[i.url?(W(),de("div",an)):fe("",!0),be("div",sn,[be("div",{class:"content content-sm text-sm",innerHTML:i.label},null,8,rn),i.url?(W(),oe(ei,{key:0,href:i.url,"aria-label":X(ze)("sharp::dashboard.widget.link_label")},{default:D(()=>t[0]||(t[0]=[be("span",{class:"absolute inset-0"},null,-1)])),_:2},1032,["href","aria-label"])):fe("",!0)]),i.count!=null?(W(),oe(X(_i),{key:1,variant:"secondary"},{default:D(()=>[xe(se(i.count),1)]),_:2},1024)):fe("",!0)]))),256))])]),_:1})]),_:1}))}}),on={class:"sr-only"},ln=We({__name:"Panel",props:{widget:{},value:{}},setup(o){return(e,t)=>(W(),oe(X(ci),{class:Yt(["relative",e.widget.link?"transition-colors hover:bg-muted/50":""])},{default:D(()=>[e.widget.title?(W(),oe(X(Tt),{key:0},{default:D(()=>[U(X(li),{class:"text-base/none font-semibold tracking-tight"},{default:D(()=>[e.widget.link?(W(),oe(ei,{key:0,class:"hover:underline",href:e.widget.link},{default:D(()=>[t[0]||(t[0]=be("span",{class:"absolute inset-0"},null,-1)),xe(" "+se(e.widget.title),1)]),_:1},8,["href"])):(W(),de(Xe,{key:1},[xe(se(e.widget.title),1)],64))]),_:1})]),_:1})):e.widget.link?(W(),oe(X(Tt),{key:1,class:"pb-0"},{default:D(()=>[U(ei,{class:"hover:underline",href:e.widget.link},{default:D(()=>[t[1]||(t[1]=be("span",{class:"absolute inset-0"},null,-1)),be("span",on,se(X(ze)("sharp::dashboard.widget.link_label")),1),xe(" "+se(e.widget.title),1)]),_:1},8,["href"])]),_:1})):fe("",!0),U(X(hi),null,{default:D(()=>[U(dr,{class:"content-sm text-sm",html:e.value.html},null,8,["html"])]),_:1})]),_:1},8,["class"]))}}),hn="en",cn={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"}},dn={name:hn,options:cn},un="fr",gn={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"}},pn={name:un,options:gn},fn="ru",xn={months:["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"],shortMonths:["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"],days:["Воскресенье","Понедельник","Вторник","Среда","Четверг","Пятница","Суббота"],shortDays:["Вс","Пн","Вт","Ср","Чт","Пт","Сб"],toolbar:{exportToSVG:"Сохранить SVG",exportToPNG:"Сохранить PNG",exportToCSV:"Сохранить CSV",menu:"Меню",selection:"Выбор",selectionZoom:"Выбор с увеличением",zoomIn:"Увеличить",zoomOut:"Уменьшить",pan:"Перемещение",reset:"Сбросить увеличение"}},mn={name:fn,options:xn},bn="es",vn={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"}},yn={name:bn,options:vn},wn="de",kn={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"}},An={name:wn,options:kn};var Cn=Qi;function Sn(){this.__data__=new Cn,this.size=0}var Ln=Sn;function Mn(o){var e=this.__data__,t=e.delete(o);return this.size=e.size,t}var Pn=Mn;function In(o){return this.__data__.get(o)}var Tn=In;function zn(o){return this.__data__.has(o)}var Xn=zn,_n=Qi,Rn=ur,En=gr,On=200;function Yn(o,e){var t=this.__data__;if(t instanceof _n){var i=t.__data__;if(!Rn||i.length- {{ value.data.figure }} - - {{ value.data.unit }} - -
- -
-
- {{ node.attrs.content }}
-