diff --git a/.gitattributes b/.gitattributes index bb6265e..3119791 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,20 @@ # Path-based git attributes # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html -# Ignore all test and documentation with "export-ignore". +# Keep development files out of the Composer package. docs, llms.txt and +# resources/boost ship with it, for AI assistants and Laravel Boost. /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore +/.github export-ignore +/.editorconfig export-ignore /phpunit.xml.dist export-ignore -/.scrutinizer.yml export-ignore +/pint.json export-ignore /tests export-ignore -/.editorconfig export-ignore +/AGENTS.md export-ignore +/CLAUDE.md export-ignore +/node_modules export-ignore +/package.json export-ignore +/pnpm-lock.yaml export-ignore +/tsconfig.json export-ignore +/vitest.config.ts export-ignore +/testbench.yaml export-ignore diff --git a/.github/coverage.svg b/.github/coverage.svg deleted file mode 100644 index 7219152..0000000 --- a/.github/coverage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - coverage - - 69 % - - \ 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 1077e5e..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - main - push: - branches: - - main - -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 }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SQLite 3 - run: | - sudo apt-get update - sudo apt-get install sqlite3 -y - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, 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' || '' }} - - - 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..f789787 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,150 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + - next-gen + +# The unreleased javaabu/* dependencies come from VCS repositories; the job's own +# token keeps Composer within GitHub's API limits +env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}' + +jobs: + pint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - name: Checkout code + 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 --no-progress + + - name: Check code style + run: vendor/bin/pint --test + + js: + runs-on: ubuntu-latest + + name: React pages (typecheck, tests) + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + # The pages import stencil's React renderer from vendor/javaabu/stencil + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false + + # The default store is inside a node_modules folder, which breaks the declaration + # build of @javaabu/forms (installed from git and built on install) + - name: Install dependencies + run: pnpm install --frozen-lockfile --store-dir "$RUNNER_TEMP/pnpm-store" + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + without-optional: + runs-on: ubuntu-latest + + name: Without optional packages + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv + coverage: none + + - name: Install dependencies without activitylog, Inertia and translatable + run: | + composer remove --dev spatie/laravel-activitylog inertiajs/inertia-laravel javaabu/translatable --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit + + 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: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} + + env: + COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv + coverage: ${{ env.COVERAGE == 'true' && 'pcov' || 'none' }} + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + if: env.COVERAGE != 'true' + run: vendor/bin/phpunit + + - name: Execute tests with coverage + if: env.COVERAGE == 'true' + run: vendor/bin/phpunit --coverage-clover build/coverage.xml + + - name: Upload coverage to Codecov + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: build/coverage.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 2a3a029..0294f77 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ composer.lock vendor coverage .phpunit.result.cache +.phpunit.cache .idea -clover.xml +node_modules diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index df16b68..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,19 +0,0 @@ -filter: - excluded_paths: [tests/*] - -checks: - php: - remove_extra_empty_lines: true - remove_php_closing_tag: true - remove_trailing_whitespace: true - fix_use_statements: - remove_unused: true - preserve_multiple: false - preserve_blanklines: true - order_alphabetically: true - fix_php_opening_tag: true - fix_linefeed: true - fix_line_ending: true - fix_identation_4spaces: true - fix_doc_comments: true - diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index f4d3cbc..0000000 --- a/.styleci.yml +++ /dev/null @@ -1,4 +0,0 @@ -preset: laravel - -disabled: - - single_class_element_per_statement diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 63e89aa..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: php - -php: - - 8.1 - - 8.2 - - 8.3 - -env: - matrix: - - COMPOSER_FLAGS="--prefer-lowest" - - COMPOSER_FLAGS="" - -before_script: - - travis_retry composer self-update - - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction --prefer-source - -script: - - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover - -after_script: - - php vendor/bin/ocular code-coverage:upload --format=php-clover coverage.clover diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4579218 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# settings: notes for AI coding agents + +`javaabu/settings` keeps app settings: defined with javaabu/stencil fields (`Setting::make()`), grouped into `SettingsPage`s and `Section`s, stored by a `SettingsStore` (database, json, array), cached under a version token that queue workers and Octane check, and edited through `Route::settings()` pages in Blade or Inertia React with a policy and permissions from javaabu/permissions. Apps extend `SettingsPage`, `SettingsController` and `SettingsPolicy`, and other Javaabu packages call the v1 helpers, so treat `Setting`, `Section`, `SettingsPage`, `SettingsRepository`/`Settings`, the helpers in `src/helpers.php`, `SettingsStore`, `SettingsUpdated`, `FakesSettings`, `Route::settings()` and the page props as public contracts. + +## Layout + +| Path | What | +| --- | --- | +| `src/SettingsRepository.php`, `src/Facades/Settings.php`, `src/helpers.php` | Reading, writing, typed getters, versioned cache, fakes; `settings()` and the v1 helpers | +| `src/SettingsRegistry.php`, `src/Definitions` | Pages and the index of settings; `Setting`, `Section`, `SettingsPage`, `InlinePage` | +| `src/Stores`, `src/Contracts` | The stores and their manager | +| `src/Forms/SettingsForm.php` | The stencil form of a page: values, saving, files, encrypted and translatable settings | +| `src/Routing`, `src/Http`, `src/Policies`, `src/Views` | `Route::settings()`, the controller, the policy, `ViewRenderer` | +| `src/Support` | Optional javaabu/translatable support (`Translations`, `Translatable\SettingsRecord`) | +| `src/Events`, `src/Listeners`, `src/Commands`, `src/Testing` | `SettingsUpdated`, the activity log listener, `settings:import-v1`, `FakesSettings` | +| `config/settings.php`, `config/defaults.php`, `database/migrations` | Options; the v1 defaults sample; the `settings` table | +| `resources/views` | The Blade page (javaabu/forms components, stencil's Blade driver) | +| `resources/js` | The Inertia React page (TypeScript), shipped through javaabu/manifest | +| `lang/en.json`, `lang/dv.json` | Translations; English strings are the keys | +| `docs`, `llms.txt` | Docusaurus pages for docs.javaabu.com and the index for AI assistants | +| `resources/boost` | Laravel Boost guideline and skill, shipped in the package | +| `tests` | Testbench tests; two guards (`web` → `User`, `web_admin` → `Admin`); `QueueWorkerProcessTest` starts a real worker with `vendor/bin/testbench` (`testbench.yaml`); `tests/js` are Vitest tests of the React page | + +## Rules + +- Follow Javaabu codestyle ([boost-javaabu-guidelines](https://github.com/Javaabu/boost-javaabu-guidelines)): snake_case variables and properties, camelCase methods, early returns, `#[Test]` and `it_…` test names. Pint enforces formatting. +- Every write must go through `SettingsRepository::set()`/`forget()`/`flush()` so the cache version changes; never cache values outside the versioned keys. +- Nothing may assume the `web` guard or `App\Models\User`. Type-hint users as `Model`; test on both guards. +- Inertia, javaabu/translatable and spatie/laravel-activitylog are optional: feature-detect them (translatable only through `Support\Translations`), keep them in `suggest`, and test both ways. Test classes must load without them (no `use` of their traits). +- Keep the v1 helpers and `FakesSettings` working; document any break in `docs/upgrading.md`. +- Encrypted values must never reach the browser or the activity log. +- Every user-facing string goes through `__()` with an English key, and gets a line in `lang/en.json` and `lang/dv.json` (`TranslationsTest` checks). Use Thaana in tests of user-facing behaviour. List uncertain translations in `docs/advanced-usage/dhivehi-and-rtl.md`. Views use logical CSS only (`ms-`, `pe-`, `start-`), never `left`/`right`. +- When the Inertia props change, regenerate the JS fixture: `UPDATE_FIXTURES=1 vendor/bin/phpunit --filter InertiaPagesTest`. +- Every change needs tests, docs (linked from `llms.txt`), the Boost skill if it changes how the package is used, and a line in `CHANGELOG.md`. +- Work on the `next-gen` branch; don't commit to `main`. + +## Commands + +```bash +composer install +composer lint # vendor/bin/pint --test +composer format # vendor/bin/pint +composer test # vendor/bin/phpunit +pnpm install && pnpm check # typecheck and Vitest for resources/js (needs vendor/ for stencil) +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8869e77..f460ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes will be documented in this file -## 1.0.0 - 202X-XX-XX +## 2.0.0 - Unreleased -- initial release +- Settings are defined with javaabu/stencil: `Setting::make()->default()->field()`, grouped into `SettingsPage`s (classes or inline) and `Section`s, or `Settings::define()` for settings without a page +- Own stores (database, json, array, custom drivers) replace anlutro/l4-settings; `settings:import-v1` copies v1 values +- `settings()` helper and `Settings` facade with typed getters, casts from the field, enums, encrypted settings and file URLs +- Values are cached under a version; queue workers (`Queue::before`) and Octane reload changed settings without a restart +- `Route::settings()`: a form, validation, saving and a page for every settings page, in Blade (javaabu/forms) or Inertia React (@javaabu/forms through javaabu/manifest), with JSON responses and live fields +- `SettingsPolicy` and `view_settings` / `edit_settings` (and per-page) permissions through javaabu/permissions; any guard and user model; overridable controller and policy +- Optional translatable settings through javaabu/translatable v3 +- Optional activity log through spatie/laravel-activitylog (no longer a dependency); encrypted values aren't logged +- Dhivehi translations and RTL pages +- Docs for people and agents, `llms.txt`, Laravel Boost guideline and skill +- Requires PHP 8.4 and Laravel 12 or 13 +- Kept from v1: `get_setting()`, `default_setting()`, `set_file_setting()`, `reset_file_setting()`, `config/defaults.php`, `FakesSettings`, `SettingsUpdated`. See `docs/upgrading.md` 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 629e598..3c4acec 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,56 @@ -# javaabu/settings +# Settings + +[![Tests](https://github.com/n4ff4h/settings/actions/workflows/tests.yml/badge.svg?branch=next-gen)](https://github.com/n4ff4h/settings/actions/workflows/tests.yml?query=branch%3Anext-gen) +[![codecov](https://codecov.io/gh/n4ff4h/settings/branch/next-gen/graph/badge.svg)](https://codecov.io/gh/n4ff4h/settings/tree/next-gen) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/settings.svg)](https://packagist.org/packages/javaabu/settings) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) + +Settings for Laravel, defined once with [javaabu/stencil](https://github.com/Javaabu/stencil): the field, its default and its validation in one place. + +- Settings pages for free: `Route::settings()` gives every page a form, validation, saving and a page in Blade or Inertia React. +- Typed values (integers, booleans, arrays, enums), encrypted secrets and file uploads. +- Cached, and picked up by queue workers and Octane without a restart. +- A policy and permissions through [javaabu/permissions](https://github.com/Javaabu/permissions), for any guard and user model. +- Dhivehi and RTL; translatable settings with [javaabu/translatable](https://github.com/Javaabu/translatable). + +```php +use Javaabu\Settings\Definitions\Setting; +use Javaabu\Settings\Definitions\SettingsPage; + +class GeneralSettings extends SettingsPage +{ + public function definition(): array + { + return [ + Setting::make('site_name')->default(config('app.name'))->label(__('Site name'))->required()->maxLength(255)->thaana(), + Setting::make('per_page')->default(20)->label(__('Items per page'))->integer()->minValue(1), + ]; + } +} +``` -[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/settings.svg?style=flat-square)](https://packagist.org/packages/javaabu/settings) -[![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/settings.svg?style=flat-square)](https://packagist.org/packages/javaabu/settings) +```php +Route::settings(); // /settings/general, for users with edit_settings -Laravel helper functions that aide in development. +settings('site_name'); +settings()->integer('per_page'); +Settings::set(['site_name' => 'ދިވެހިރާއްޖެ']); +``` +Coming from v1? `get_setting()` still works; see [Upgrading](docs/upgrading.md). ## Documentation -You'll find the documentation on [https://docs.javaabu.com/settings](https://docs.javaabu.com/settings). +You'll find the documentation on [https://docs.javaabu.com/settings](https://docs.javaabu.com/settings), and in [`docs/`](docs/intro.md). -Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the media library? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible. +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 -./vendor/bin/phpunit +```bash +composer test ``` ## Changelog @@ -40,10 +68,10 @@ If you discover any security related issues, please email [info@javaabu.com](mai ## Credits - [Javaabu Pvt. Ltd.](https://github.com/javaabu) -- [Athfan Khaleel (@athphane)](http://athfan.com) +- [Arushad Ahmed (@dash8x)](http://arushad.com) +- [Athfan Khaleel (@athphane)](https://github.com/athphane) - [All Contributors](../../contributors) - ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json index 0547a69..9fba674 100644 --- a/composer.json +++ b/composer.json @@ -1,50 +1,116 @@ { "name": "javaabu/settings", - "description": "File based settings system for Laravel", + "description": "Settings for Laravel defined once with javaabu/stencil: the field, default and validation in one place, with typed access, automatic settings pages in Blade or Inertia React, and queue workers that pick up changes", "homepage": "https://github.com/javaabu/settings", "license": "MIT", "keywords": [ "javaabu", "laravel", - "helpers", - "settings" + "settings", + "stencil", + "inertia", + "dhivehi" ], "authors": [ { "name": "Athfan Khaleel", "email": "athfan@javaabu.com" + }, + { + "name": "Javaabu Pvt. Ltd.", + "email": "info@javaabu.com", + "homepage": "https://javaabu.com", + "role": "Developer" + } + ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Javaabu/forms" + }, + { + "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/translatable" } ], "require": { - "php": "^8.1", - "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", - "anlutro/l4-settings": "^1.4", - "spatie/laravel-activitylog": "^4.8" + "php": "^8.4", + "illuminate/cache": "^12.0 || ^13.0", + "illuminate/console": "^12.0 || ^13.0", + "illuminate/contracts": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/http": "^12.0 || ^13.0", + "illuminate/queue": "^12.0 || ^13.0", + "illuminate/routing": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "illuminate/validation": "^12.0 || ^13.0", + "illuminate/view": "^12.0 || ^13.0", + "javaabu/forms": "^2.0@dev", + "javaabu/manifest": "^1.0@dev", + "javaabu/permissions": "^3.0@dev", + "javaabu/stencil": "^1.0@dev" }, "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" + "inertiajs/inertia-laravel": "^2.0", + "javaabu/boost-javaabu-guidelines": "^1.4", + "javaabu/translatable": "^3.0@dev", + "laravel/pint": "^1.24", + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.5.3 || ^12.5.8", + "spatie/laravel-activitylog": "^4.10" + }, + "suggest": { + "inertiajs/inertia-laravel": "Renders the settings pages with Inertia React when settings.views is inertia (^2.0)", + "javaabu/translatable": "One value per language for translatable settings (^3.0)", + "spatie/laravel-activitylog": "Logs every change to the settings (^4.10)" }, "autoload": { "psr-4": { "Javaabu\\Settings\\": "src/" - } + }, + "files": [ + "src/helpers.php" + ] }, "autoload-dev": { "psr-4": { "Javaabu\\Settings\\Tests\\": "tests/" } }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-clover build/coverage.xml", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint" + }, "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-next-gen": "2.x-dev" }, "laravel": { "providers": [ "Javaabu\\Settings\\SettingsServiceProvider" - ] + ], + "aliases": { + "Settings": "Javaabu\\Settings\\Facades\\Settings" + } } }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/defaults.php b/config/defaults.php index cec67ea..c326d4a 100644 --- a/config/defaults.php +++ b/config/defaults.php @@ -4,7 +4,6 @@ /** * Default Settings */ - 'app_name' => env('APP_NAME', 'Laravel'), 'favicon' => 'favicon.ico', diff --git a/config/settings.php b/config/settings.php new file mode 100644 index 0000000..d70b4ef --- /dev/null +++ b/config/settings.php @@ -0,0 +1,148 @@ + env('SETTINGS_STORE', 'database'), + + 'stores' => [ + 'database' => [ + 'connection' => env('SETTINGS_DB_CONNECTION'), + 'table' => 'settings', + ], + + 'json' => [ + 'path' => storage_path('app/settings.json'), + ], + + 'array' => [], + ], + + /* + |-------------------------------------------------------------------------- + | Cache + |-------------------------------------------------------------------------- + | + | Values are cached forever and forgotten when a setting changes, so a + | request reads the store at most once. store null means the app's default + | cache store. Use a store every process shares (redis, database, file) + | so queue workers and Octane see changes: see refresh below. + | + */ + + 'cache' => [ + 'enabled' => env('SETTINGS_CACHE', true), + 'store' => env('SETTINGS_CACHE_STORE'), + 'prefix' => 'javaabu_settings', + ], + + /* + |-------------------------------------------------------------------------- + | Refreshing long-running processes + |-------------------------------------------------------------------------- + | + | Every change puts a new version in the cache. Queue workers check it + | before each job and Octane before each request, task and tick, and + | reload the settings when it changed. + | + */ + + 'refresh' => [ + 'queue' => env('SETTINGS_REFRESH_QUEUE', true), + 'octane' => env('SETTINGS_REFRESH_OCTANE', true), + ], + + /* + |-------------------------------------------------------------------------- + | Pages + |-------------------------------------------------------------------------- + | + | SettingsPage classes to register. You can also register pages from a + | service provider with Settings::page(). + | + */ + + 'pages' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Files + |-------------------------------------------------------------------------- + | + | Where file and image settings are uploaded. disk null means the app's + | default filesystem disk; use a public disk for files shown on the site. + | + */ + + 'disk' => env('SETTINGS_DISK', 'public'), + + 'directory' => 'settings', + + /* + |-------------------------------------------------------------------------- + | Views + |-------------------------------------------------------------------------- + | + | The pages the settings routes render: blade (javaabu/forms components, + | through stencil's Blade driver) or inertia (React pages shipped through + | javaabu/manifest). Requests that expect JSON always get JSON. + | + */ + + 'views' => env('SETTINGS_VIEWS', 'blade'), + + /* + | The Blade layout the pages extend, and the section they fill. + */ + + 'layout' => 'layouts.app', + + 'layout_section' => 'content', + + /* + |-------------------------------------------------------------------------- + | Controllers and authorization + |-------------------------------------------------------------------------- + | + | Replace the controller with a subclass, or pass `controller` to + | Route::settings(). The policy here is the package default; apps can + | also override it with config('permissions.policies') or Gate::policy(). + | Turn off register_permissions to register your own. + | + */ + + 'controllers' => [ + 'settings' => SettingsController::class, + ], + + 'policy' => SettingsPolicy::class, + + 'register_permissions' => true, + + /* + |-------------------------------------------------------------------------- + | Activity log + |-------------------------------------------------------------------------- + | + | With spatie/laravel-activitylog installed, every change is logged with + | the old and new values, caused by the user who made it. + | + */ + + 'log_activity' => true, + +]; diff --git a/database/migrations/2026_11_04_000000_create_settings_table.php b/database/migrations/2026_11_04_000000_create_settings_table.php new file mode 100644 index 0000000..237acec --- /dev/null +++ b/database/migrations/2026_11_04_000000_create_settings_table.php @@ -0,0 +1,33 @@ +table(), function (Blueprint $table) { + $table->id(); + $table->string('key')->unique(); + $table->longText('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->table()); + } + + protected function table(): string + { + return config('settings.stores.database.table', 'settings'); + } +}; diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json new file mode 100644 index 0000000..dc92680 --- /dev/null +++ b/docs/advanced-usage/_category_.json @@ -0,0 +1,11 @@ +{ + "position": 1.31, + "label": "Advanced Usage", + "collapsible": true, + "collapsed": false, + "link": { + "type": "generated-index", + "slug": "/settings/_categories/advanced-usage", + "description": "Stores, caching, queue workers, permissions, overrides, translations and every option." + } +} diff --git a/docs/advanced-usage/activity-log.md b/docs/advanced-usage/activity-log.md new file mode 100644 index 0000000..17f90b0 --- /dev/null +++ b/docs/advanced-usage/activity-log.md @@ -0,0 +1,30 @@ +--- +title: Activity log +sidebar_position: 6 +--- + +With [spatie/laravel-activitylog](https://github.com/spatie/laravel-activitylog) installed, every change is logged: + +```bash +composer require spatie/laravel-activitylog +php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations" +php artisan migrate +``` + +Each change is an activity with the description `settings`, caused by the user who made it, with the changed keys only: + +```php +$activity->properties['attributes']; // ['site_name' => 'ދިވެހިރާއްޖެ'] +$activity->properties['old']; // ['site_name' => 'Laravel'] +``` + +Encrypted settings are logged as `********`, before and after. + +Without the package, nothing is logged. To turn logging off with it installed: + +```php +// config/settings.php +'log_activity' => false, +``` + +To log differently, listen to `Javaabu\Settings\Events\SettingsUpdated` (see [Reading and writing](../basic-usage/reading-and-writing.md#the-settingsupdated-event)). diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md new file mode 100644 index 0000000..be67ced --- /dev/null +++ b/docs/advanced-usage/api-reference.md @@ -0,0 +1,137 @@ +--- +title: API reference +sidebar_position: 9 +--- + +# Helpers + +| Function | Returns | +| --- | --- | +| `settings()` | The `SettingsRepository` | +| `settings(string $key, mixed $default = null)` | A setting's value | +| `settings(array $values)` | Sets the values; returns the repository | +| `get_setting(string $key)` | A setting's value (v1) | +| `default_setting(string $key)` | A setting's default (v1) | +| `set_file_setting(string $key, string $path = 'storage/uploads')` | Saves the request's upload under `$key` into `public/{$path}` (v1) | +| `reset_file_setting(string $key)` | Deletes the upload from the public disk and resets the setting (v1) | +| `remove_prefix(string $needle, string $haystack)` | `$haystack` without `$needle` at its start (v1) | + +# `Javaabu\Settings\Facades\Settings` / `SettingsRepository` + +Reading: + +| Method | Returns | +| --- | --- | +| `get(string $key, mixed $default = null)` | The value: stored, else the definition's default, else `config('defaults.{key}')`, else `$default` | +| `string()`, `integer()`, `float()`, `boolean()`, `array()` `(string $key, $default = null)` | The value, or a `SettingsException` when it's another type | +| `default(string $key)` | The default | +| `has(string $key)` | Whether a value is stored | +| `all()` | Every setting's value | +| `only(array $keys)` | Those settings' values | +| `stored()` | The stored values as the store keeps them (encrypted settings encrypted) | +| `url(string $key)` | The URL of a file setting | +| `translate(string $key, ?string $locale = null, bool $fallback = true)` | A translatable setting in a locale | +| `translations(string $key)` | A translatable setting's values, keyed by locale | + +Writing: + +| Method | What | +| --- | --- | +| `set(string\|array $key, mixed $value = null, ?Model $causer = null)` | Store values; fires `SettingsUpdated` when something changed | +| `forget(string\|array $keys, ?Model $causer = null)` | Remove stored values (back to defaults) | +| `flush()` | Remove every stored value | + +Processes: + +| Method | What | +| --- | --- | +| `refreshIfStale()` | Reload if another process changed settings; returns whether it did | +| `refresh()` | Forget the loaded values | +| `touch()` | Tell every process to reload | +| `version()` | The cache version the values were loaded at | + +Definitions: + +| Method | What | +| --- | --- | +| `page(SettingsPage\|string $page)` | Register a page class or instance, or get (or start) an inline page by key | +| `define(array\|Setting $settings)` | Define settings without a page | +| `pages()` | The visible pages, in order, keyed by key | +| `definition(string $key)` | A `Setting`, or `null` | +| `registry()` | The `SettingsRegistry` | + +Stores and testing: + +| Method | What | +| --- | --- | +| `store()` | The `SettingsStore` in use | +| `extend(string $driver, Closure $creator)` | Add a store driver | +| `fake(array $values = [])` | Keep values in memory from now on | +| `fakeDefault(string $key, mixed $value)` | Override a default | +| `isFaking()` | Whether values are faked | +| `stopFaking()` | Back to the store | +| `Settings::ignoreMigrations()` | Don't load the package's migration | + +# `Javaabu\Settings\Definitions\Setting` + +| Method | What | +| --- | --- | +| `Setting::make(string $key)` | A setting | +| `default(mixed $value)` | The default; a closure is called each time | +| `field(Field\|string\|Closure $field)` | The stencil field: a class, a field named after the key, or a closure given the key. Default `TextInput` | +| `cast(string\|Closure\|null $cast)` | `string`, `integer`, `float`, `boolean`, `array`, an enum class or a closure | +| `encrypted(bool $condition = true)` | Encrypt the stored value | +| `translatable(bool $condition = true)` | A value per language, with javaabu/translatable | +| any field method | Passed to the field (`label()`, `required()`, `options()`, …) | +| `getKey()`, `getDefault()`, `getField()`, `getCast()`, `isEncrypted()`, `isTranslatable()`, `isFile()` | | +| `castValue(mixed $value)`, `storableValue(mixed $value)` | Cast a value for reading, or for storing | + +# `Javaabu\Settings\Definitions\Section` + +| Method | What | +| --- | --- | +| `Section::make($heading = null, array\|Closure $schema = [])` | A section | +| `schema(array\|Closure $schema)` | Its settings and sections | +| any stencil section method | `description()`, `columns()`, … | + +# `Javaabu\Settings\Definitions\SettingsPage` + +Abstract. Implement `definition(): array`. Override `getKey()`, `getLabel()`, `getDescription()`, `getIcon()`, `getPermission()`, `getOrder()` and `isVisible()`, or set `$key`, `$permission` and `$order`. Also: `settings()` (keyed by key), `setting($key)`, `has($key)`, `components()` (the stencil components). + +`InlinePage` (from `Settings::page('key')`) adds `schema()`, `label()`, `description()`, `icon()`, `permission()` and `order()`. + +# `Javaabu\Settings\Forms\SettingsForm` + +A `Javaabu\Stencil\Form`. `forPage(SettingsPage|string $page, ?string $guard = null)`, `saveSettings(Request|array|null $input = null, ?Model $causer = null)`, `currentValues()`, `page()`, `guard()`. + +# Routes and HTTP + +- `Route::settings(array $options = [])`: see [Settings pages](../basic-usage/settings-pages.md#routes). +- `Javaabu\Settings\Routing\SettingsRoutes::guard(Request $request)` and `namePrefix(Request $request)`. +- `Javaabu\Settings\Http\Controllers\SettingsController`: see [Overriding controllers and views](overriding-controllers-and-views.md). +- `Javaabu\Settings\Policies\SettingsPolicy`: `viewAny`, `view`, `update`; constants `VIEW` (`view_settings`) and `EDIT` (`edit_settings`). +- `Javaabu\Settings\Views\ViewRenderer`: renders `settings.edit` as Blade or `settings::Settings/Edit` with Inertia. + +# Other classes + +| Class | What | +| --- | --- | +| `Contracts\SettingsStore` | `all()`, `put(array)`, `forget(array)`, `flush()` | +| `Stores\DatabaseStore`, `Stores\JsonStore`, `Stores\ArrayStore`, `Stores\StoreManager` | The stores | +| `Events\SettingsUpdated` | `$old_settings`, `$new_settings`, `$causer`, `changes()`, `changed($key)` | +| `Listeners\LogSettingsUpdate` | Logs to spatie/laravel-activitylog | +| `Testing\FakesSettings` | `setFakeSetting()`, `setFakeDefaultSetting()`, `setFakeSettings()` | +| `Support\Translations` | The optional javaabu/translatable support | +| `Exceptions\SettingsException` | Wrong types, bad definitions, unknown pages | + +# Commands + +`php artisan settings:import-v1`: see [Upgrading](../upgrading.md#move-the-values). + +# JavaScript (`@javaabu/settings`) + +| Export | What | +| --- | --- | +| `SettingsEdit` | The `settings::Settings/Edit` page | +| `SettingsNav` | `{ pages, label?, className? }`: tabs to each page | +| `SettingsEditProps`, `SettingsPageData`, `SettingsStrings` | Types | diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md new file mode 100644 index 0000000..698263e --- /dev/null +++ b/docs/advanced-usage/configuration.md @@ -0,0 +1,32 @@ +--- +title: Configuration +sidebar_position: 1 +--- + +```bash +php artisan vendor:publish --tag=settings-config +``` + +| Key | Env | Default | What | +| --- | --- | --- | --- | +| `store` | `SETTINGS_STORE` | `database` | Where values are kept: `database`, `json`, `array`, or a driver added with `Settings::extend()`. See [Stores and caching](stores-and-caching.md) | +| `stores.database.connection` | `SETTINGS_DB_CONNECTION` | `null` (the default connection) | The connection of the settings table; the migration uses it too | +| `stores.database.table` | | `settings` | The table; set it before migrating | +| `stores.json.path` | | `storage_path('app/settings.json')` | The JSON file | +| `cache.enabled` | `SETTINGS_CACHE` | `true` | Cache the values. Off: the store is read once per request (or job) | +| `cache.store` | `SETTINGS_CACHE_STORE` | `null` (the default cache store) | The cache store; use one every process shares | +| `cache.prefix` | | `javaabu_settings` | The prefix of the cache keys | +| `refresh.queue` | `SETTINGS_REFRESH_QUEUE` | `true` | Workers reload changed settings before each job. See [Queue workers and Octane](queue-workers-and-octane.md) | +| `refresh.octane` | `SETTINGS_REFRESH_OCTANE` | `true` | Octane reloads changed settings before each request, task and tick | +| `pages` | | `[]` | `SettingsPage` classes to register | +| `disk` | `SETTINGS_DISK` | `public` | Where file settings are uploaded (`null`: the default disk) | +| `directory` | | `settings` | The folder on the disk | +| `views` | `SETTINGS_VIEWS` | `blade` | The pages' stack: `blade` or `inertia` | +| `layout` | | `layouts.app` | The Blade layout the page extends | +| `layout_section` | | `content` | The section it fills | +| `controllers.settings` | | `SettingsController::class` | The controller `Route::settings()` uses | +| `policy` | | `SettingsPolicy::class` | The default policy of settings pages | +| `register_permissions` | | `true` | Register `view_settings`, `edit_settings` and the pages' permissions with javaabu/permissions | +| `log_activity` | | `true` | Log changes when spatie/laravel-activitylog is installed | + +`config/defaults.php` (publish it with `--tag=settings-defaults`) still works as in v1: a `key => default` array for settings without a default in their definition. diff --git a/docs/advanced-usage/dhivehi-and-rtl.md b/docs/advanced-usage/dhivehi-and-rtl.md new file mode 100644 index 0000000..fe3bb44 --- /dev/null +++ b/docs/advanced-usage/dhivehi-and-rtl.md @@ -0,0 +1,46 @@ +--- +title: Dhivehi and RTL +sidebar_position: 8 +--- + +# Strings + +Every string the package shows is translated into Dhivehi, in `lang/dv.json` (JSON translations, keyed by the English string). Publish them to change them: + +```bash +php artisan vendor:publish --tag=settings-translations +``` + +Your settings' labels, help texts, section headings, page labels and options are yours: pass them through `__()` and translate them in your app. + +# Right to left + +The Blade and React pages use logical layout only (no left or right), so they work in RTL: set `dir="rtl"` on the `` of your layout for Dhivehi. The fields come from javaabu/forms and @javaabu/forms, which support RTL. + +# Thaana + +Add `->thaana()` to text settings that take Dhivehi. The input gets `dir="rtl"`, `lang="dv"` and the Thaana keyboard: + +```php +Setting::make('site_name')->label(__('Site name'))->thaana(); +Setting::make('seo_description')->field(Textarea::class)->thaana(); +``` + +For a value per language, see [Translatable settings](translatable-settings.md). + +# Translations to review + +These were translated by the package's authors without a native speaker's review. Corrections are welcome. + +| English | Dhivehi | +| --- | --- | +| Settings | ސެޓިންގްސް | +| Settings pages | ސެޓިންގްސް ސަފްޙާތައް | +| Save settings | ސެޓިންގްސް ސޭވްކުރޭ | +| Settings saved. | ސެޓިންގްސް ސޭވްކުރެވިއްޖެ. | +| You can view these settings but not change them. | ތިބާއަށް މި ސެޓިންގްސް ބެލޭނެ، ނަމަވެސް ބަދަލެއް ނުކުރެވޭނެ. | +| Leave blank to keep the current value. | މިހާރުގެ އަގު ބަދަލު ނުކުރަން ބޭނުންނަމަ ހުސްކޮށް ދޫކޮށްލައްވާ. | +| View settings | ސެޓިންގްސް ބެލުން | +| Edit settings | ސެޓިންގްސް ބަދަލުކުރުން | + +The descriptions of per-page permissions ("Edit Mail settings") are generated from the page key and aren't translated; register the permission yourself with a translated description if you need one. diff --git a/docs/advanced-usage/overriding-controllers-and-views.md b/docs/advanced-usage/overriding-controllers-and-views.md new file mode 100644 index 0000000..ae856d7 --- /dev/null +++ b/docs/advanced-usage/overriding-controllers-and-views.md @@ -0,0 +1,64 @@ +--- +title: Overriding controllers and views +sidebar_position: 5 +--- + +# The controller + +Extend `Javaabu\Settings\Http\Controllers\SettingsController` and point the config at it, or pass it to one set of routes: + +```php +// config/settings.php +'controllers' => [ + 'settings' => App\Http\Controllers\SettingsController::class, +], + +// routes/web.php +Route::settings(['controller' => App\Http\Controllers\SettingsController::class]); +``` + +The methods to override: + +| Method | What | +| --- | --- | +| `index(Request $request)` | Redirect to the first page (JSON: the pages) | +| `edit(Request $request, string $settings_page)` | Show a page | +| `update(Request $request, string $settings_page)` | Save a page | +| `updated(Request $request, SettingsPage $page, array $values)` | Called after a page is saved, with the values stored. Empty by default | +| `form(Request $request, SettingsPage $page)` | The page's `SettingsForm` | +| `viewData(Request $request, SettingsPage $page, SettingsForm $form)` | What the view or Inertia page gets | +| `navigation(Request $request, ?SettingsPage $current)` | The pages for the tabs | + +```php +namespace App\Http\Controllers; + +use Illuminate\Http\Request; +use Javaabu\Settings\Definitions\SettingsPage; +use Javaabu\Settings\Http\Controllers\SettingsController as BaseController; + +class SettingsController extends BaseController +{ + protected function updated(Request $request, SettingsPage $page, array $values): void + { + if (array_key_exists('site_name', $values)) { + Artisan::call('sitemap:generate'); + } + } +} +``` + +# The Blade view + +```bash +php artisan vendor:publish --tag=settings-views +``` + +Edit `resources/views/vendor/settings/settings/edit.blade.php`. It gets the variables listed in [Settings pages](../basic-usage/settings-pages.md#blade). Or keep the package's view and only change the layout with `settings.layout` and `settings.layout_section`. + +# The React page + +Copy `vendor/javaabu/settings/resources/js/Pages/Settings/Edit.tsx` to `resources/vendor/settings/js/Pages/Settings/Edit.tsx` and change it: manifest's `resolvePage()` uses the app's copy first. Import the package's pieces as `@javaabu/settings` (`SettingsNav`, the prop types) and stencil's as `@javaabu/stencil`. + +# The form fields + +A setting's field is a stencil field, so its look comes from javaabu/forms (Blade) and @javaabu/forms (React). Use any stencil field, including your own (`php artisan make:stencil-field`), with `->field(MyField::class)`. diff --git a/docs/advanced-usage/permissions-and-policies.md b/docs/advanced-usage/permissions-and-policies.md new file mode 100644 index 0000000..bd72a7d --- /dev/null +++ b/docs/advanced-usage/permissions-and-policies.md @@ -0,0 +1,92 @@ +--- +title: Permissions and policies +sidebar_position: 4 +--- + +# Permissions + +The package registers these with [javaabu/permissions](https://github.com/Javaabu/permissions), in a `settings` group: + +| Permission | Lets a user | +| --- | --- | +| `view_settings` | See every page (the form is disabled) | +| `edit_settings` | See and save every page that doesn't have its own permission | + +Create them with `php artisan permissions:sync`, for every guard in `config('permissions.guards')`, and give them to roles as usual. + +## A permission per page + +Give a page its own permission to let some users edit only it: + +```php +class MailSettings extends SettingsPage +{ + protected ?string $permission = 'edit_mail_settings'; + + // ... +} + +Settings::page('payments')->permission('edit_payment_settings')->schema([...]); +``` + +Page permissions are registered in the `settings` group too ("Edit Mail settings"), unless you registered them yourself. `edit_settings` doesn't cover pages that have their own permission. + +Turn registration off with `'register_permissions' => false` to register your own. + +# The policy + +`Javaabu\Settings\Policies\SettingsPolicy` is the policy of `SettingsPage` (every page class): + +| Ability | Allowed with | +| --- | --- | +| `viewAny` | `view_settings`, `edit_settings`, or any page's permission | +| `view` | `view_settings`, or what `update` needs | +| `update` | The page's permission, or `edit_settings` when it has none | + +```php +Gate::allows('update', Settings::page('mail')); +$user->can('view', Settings::registry()->find('general')); +``` + +Replace it, in order of precedence: + +```php +// config/permissions.php +'policies' => [ + Javaabu\Settings\Definitions\SettingsPage::class => App\Policies\SettingsPolicy::class, +], + +// or in a service provider +Gate::policy(SettingsPage::class, App\Policies\SettingsPolicy::class); + +// or config/settings.php +'policy' => App\Policies\SettingsPolicy::class, +``` + +Policy methods get the user as `Illuminate\Database\Eloquent\Model`, so any user model works: + +```php +namespace App\Policies; + +use Illuminate\Database\Eloquent\Model; +use Javaabu\Settings\Definitions\SettingsPage; +use Javaabu\Settings\Policies\SettingsPolicy as BasePolicy; + +class SettingsPolicy extends BasePolicy +{ + public function update(Model $user, SettingsPage $page): bool + { + return $page->getKey() !== 'billing' || $user->is_owner; + } +} +``` + +# Guards and user models + +Nothing assumes the `web` guard or `App\Models\User`. Pass the guard to the routes, and permissions are checked for that guard's user: + +```php +Route::settings(['guard' => 'web_admin', 'prefix' => 'admin/settings', 'name' => 'admin.settings']); +``` + +The user who saves a page is recorded as the causer of the [activity log](activity-log.md) and the `SettingsUpdated` event, whatever its model. diff --git a/docs/advanced-usage/queue-workers-and-octane.md b/docs/advanced-usage/queue-workers-and-octane.md new file mode 100644 index 0000000..a828152 --- /dev/null +++ b/docs/advanced-usage/queue-workers-and-octane.md @@ -0,0 +1,56 @@ +--- +title: Queue workers and Octane +sidebar_position: 3 +--- + +Queue workers and Octane boot the app once and keep it in memory, so a process that loaded the settings would keep using them after an admin changed them, until it restarted. This package makes them reload changed settings, without a restart. + +# How it works + +Every change puts a new version in the cache. Before each job, a worker compares the version it loaded with the cache's version, and reloads the settings when they differ (a `JobProcessing` listener, which is what `Queue::before()` registers). Octane does the same before each request, task and tick. The check is one cache read. + +```php +// What the service provider does +Event::listen(JobProcessing::class, fn () => Settings::refreshIfStale()); +Event::listen([RequestReceived::class, TaskReceived::class, TickReceived::class], fn () => Settings::refreshIfStale()); +``` + +For this to work across processes and servers, use a shared cache store (Redis, Memcached, database; file on a single server). With `cache.enabled` off, workers read the store before every job instead. + +A job that runs for a long time reads the settings it had when it started. Call `Settings::refreshIfStale()` yourself between steps if it must see changes sooner. + +# Turning it off + +```php +// config/settings.php +'refresh' => [ + 'queue' => false, // SETTINGS_REFRESH_QUEUE=false + 'octane' => false, // SETTINGS_REFRESH_OCTANE=false +], +``` + +# Testing + +The package's own tests start a real worker (`vendor/bin/testbench queue:work`), change a setting from the test process while it runs, and check that its next job sees the new value. In an app, you can test the same thing in-process: run the worker's next job, change the setting the way another process would (with a repository of its own), and run the next job. + +```php +use Illuminate\Queue\WorkerOptions; +use Javaabu\Settings\SettingsRepository; + +#[Test] +public function workers_see_changed_settings(): void +{ + config(['queue.default' => 'database']); + + SendNewsletter::dispatch(); + $this->app['queue.worker']->runNextJob('database', 'default', new WorkerOptions); + + // another process (a web request) changes the setting + $this->app->build(SettingsRepository::class)->set('newsletter_from', 'news@example.com'); + + SendNewsletter::dispatch(); + $this->app['queue.worker']->runNextJob('database', 'default', new WorkerOptions); + + // assert the second job used news@example.com +} +``` diff --git a/docs/advanced-usage/stores-and-caching.md b/docs/advanced-usage/stores-and-caching.md new file mode 100644 index 0000000..4069b93 --- /dev/null +++ b/docs/advanced-usage/stores-and-caching.md @@ -0,0 +1,74 @@ +--- +title: Stores and caching +sidebar_position: 2 +--- + +# Stores + +| Store | Keeps values | Use | +| --- | --- | --- | +| `database` (default) | In the `settings` table, a row per setting with the value as JSON | Most apps | +| `json` | In a JSON file (`storage/app/settings.json`), written atomically | Apps without a database; a single server | +| `array` | In memory, for the request | Tests | + +```php +// config/settings.php +'store' => 'json', +'stores' => [ + 'json' => ['path' => storage_path('app/settings.json')], +], +``` + +## Your own store + +Implement `Javaabu\Settings\Contracts\SettingsStore` and register it as a driver, in a service provider's `register()` or `boot()`: + +```php +use Javaabu\Settings\Contracts\SettingsStore; +use Javaabu\Settings\Facades\Settings; + +class DynamoDbStore implements SettingsStore +{ + public function all(): array { /* every value, keyed by setting key */ } + public function put(array $values): void { /* store these values */ } + public function forget(array $keys): void { /* remove these keys */ } + public function flush(): void { /* remove everything */ } +} + +Settings::extend('dynamodb', fn ($app, array $config) => new DynamoDbStore($config['table'])); +``` + +```php +// config/settings.php +'store' => 'dynamodb', +'stores' => [ + 'dynamodb' => ['table' => 'settings'], +], +``` + +The closure gets the app and `config('settings.stores.{driver}')`. `Settings::store()` returns the store in use. + +# Caching + +The first read in a process loads every value: from the cache when it has them, otherwise from the store, which is then cached. Reads after that come from memory, so `settings()` costs nothing in a loop. + +The cache holds a **version**, and the values are cached under it. Every write stores the values, then puts a new version in the cache. Processes that loaded the old version see the new one and reload (see [Queue workers and Octane](queue-workers-and-octane.md)). Because the values are keyed by version, a process that read the store just before a write can't cache stale values under the new version. + +- Use a cache store every process and server shares: Redis, Memcached, database or (on one server) file. With `array`, each process has its own cache. +- `cache.enabled` off reads the store once per request or job instead. +- Clearing the cache (`php artisan cache:clear`) is safe: values are read from the store again. + +`Settings::refresh()` forgets what the current process loaded; `Settings::touch()` tells every process to reload. + +# The table + +```php +Schema::create('settings', function (Blueprint $table) { + $table->id(); + $table->string('key')->unique(); + $table->longText('value')->nullable(); // JSON + $table->timestamps(); +}); +``` + +Values are JSON-encoded, so types survive: `20` is an integer, `true` a boolean, lists are arrays. Encrypted settings hold the encrypted string. diff --git a/docs/advanced-usage/translatable-settings.md b/docs/advanced-usage/translatable-settings.md new file mode 100644 index 0000000..432c3d7 --- /dev/null +++ b/docs/advanced-usage/translatable-settings.md @@ -0,0 +1,61 @@ +--- +title: Translatable settings +sidebar_position: 7 +--- + +With [javaabu/translatable](https://github.com/Javaabu/translatable) v3 installed, a setting can have a value per language: the site name in English and in Dhivehi. + +```bash +composer require javaabu/translatable:^3.0@dev +``` + +```php +Setting::make('site_title')->default('Maldives')->label(__('Site title'))->required()->maxLength(100)->translatable(); +Setting::make('footer_text')->field(Textarea::class)->label(__('Footer'))->translatable(); +``` + +`->translatable()` is always safe. When translatable is installed and enabled (`translatable.enabled`, with active languages), the setting is translatable; otherwise it's a plain setting. + +# The settings page + +A translatable setting has one input per active language, named `{key}_{locale}` (`site_title_en`, `site_title_dv`), in tabs, through stencil's [translatable fields](https://github.com/Javaabu/stencil/blob/main/docs/advanced-usage/translatable.md). Each input has its language's direction and `lang`, and Dhivehi gets the Thaana keyboard. Blade and React render the same inputs. + +The default language keeps the setting's rules; the others are optional (`required` becomes `nullable`). To require every language, or to stack the inputs instead of tabs: + +```php +Setting::make('site_title')->required()->translatable(require_all: true); +Setting::make('footer_text')->field(Textarea::class)->translatable(layout: 'stacked'); +``` + +# Reading + +```php +settings('site_title'); // in the current language, falling back to the default language +Settings::translate('site_title', 'dv'); // in Dhivehi, falling back +Settings::translate('site_title', 'dv', fallback: false); // in Dhivehi, or null +Settings::translations('site_title'); // ['en' => 'Maldives', 'dv' => 'ދިވެހިރާއްޖެ'] +``` + +The current language is translatable's (`Translatable::currentLocale()`), so its locale middleware and language switcher apply. + +# Writing + +A translatable setting is stored as a map of locale to value: + +```php +Settings::set('site_title', ['en' => 'Maldives', 'dv' => 'ދިވެހިރާއްޖެ']); +``` + +The settings page merges what's submitted into what's stored, so a language that isn't active keeps its value. + +# Without translatable + +- Before you install it, or with `translatable.enabled` off, a translatable setting is a plain setting: one input, a plain value. +- Values stored as a map are still read in the current app locale, falling back to `app.fallback_locale`, then to the first value that isn't empty. So turning translations off doesn't break reading. +- Saving the page with translations off stores a plain value again. + +# How it works + +When the page has a translatable setting, its form edits a `Javaabu\Settings\Support\Translatable\SettingsRecord` instead of an array: a JSON-translatable model, never saved, with the default language's values as attributes and the others in `translations`. That is what stencil and translatable expect, so the fields, rules and old input work as for any translatable model. The record class only loads when translatable is installed. + +`Javaabu\Settings\Support\Translations` is the one place the package checks for translatable. Set `Translations::$enabled = false` to turn translatable settings off while keeping the package installed for your models. diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md new file mode 100644 index 0000000..3c93dd0 --- /dev/null +++ b/docs/ai-assistants.md @@ -0,0 +1,34 @@ +--- +title: AI assistants +sidebar_position: 1.35 +--- + +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/settings/llms.txt` | The rules for using the package, and every page | +| `vendor/javaabu/settings/docs/` | The pages | +| `vendor/javaabu/settings/resources/boost/` | The Laravel Boost skill and guideline | + +# Laravel Boost + +[Laravel Boost](https://github.com/laravel/boost) finds the skill and guideline by itself in Composer packages. Run this after installing or updating the package: + +```bash +php artisan boost:update +``` + +Choose `javaabu/settings` if Boost asks which third-party packages to use. + +# Other assistants + +Point your assistant at the docs from the project's instructions file (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, `.github/copilot-instructions.md`): + +```md +## Settings + +App settings use javaabu/settings. Before adding or reading a setting, read vendor/javaabu/settings/llms.txt. +``` + +Or copy `vendor/javaabu/settings/resources/boost/skills/javaabu-settings` into your assistant's skills folder, such as `.claude/skills/`. diff --git a/docs/basic-usage/available-methods.md b/docs/basic-usage/available-methods.md deleted file mode 100644 index 6c5d6c0..0000000 --- a/docs/basic-usage/available-methods.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Available Helper Methods -sidebar_position: 2 ---- - -This package provides the following helper methods: - -- **`default_setting(string $key)`**: Returns a default setting value from the `config\defaults.php` file. -- **`get_setting(string $key)`**: Returns the value of the save setting for the given setting key. If a value is not saved, returns the default value for the setting. -- **`set_file_setting(string $key, string $path = 'storage/uploads')`**: Saves an uploaded file from the current request to the given path and sets the setting value to the uploaded file url. Note that the uploaded file must have the same input name as the setting key. If the request has an empty value for the input name, then the setting would be reset to its default value. -- **`reset_file_setting(string $key)`**: Deletes the saved file for the given file setting and resets its value to its default value. - diff --git a/docs/basic-usage/basic-concepts.md b/docs/basic-usage/basic-concepts.md deleted file mode 100644 index 35bb7b1..0000000 --- a/docs/basic-usage/basic-concepts.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: Basic Concepts -sidebar_position: 1 ---- - -For each setting you use, you should have a default value in your `config/defaults.php` file with that setting key. - -```php - env('APP_NAME', 'Laravel'), - - 'per_page' => 10, - - 'logo' => 'img/logo.png', - - 'featured_categories' => [ - 'books', - 'fruits', - 'electronics' - ] -]; - -``` - -## General usage - -To use a setting value in your code, simply call `get_setting`. - -```php -$app_name = get_setting('app_name'); -``` - -To set a setting value, use the `Setting` facade's `set` method. You can see full instructions at [`aluntro/l4-settings`](https://github.com/anlutro/laravel-settings). - -## File settings - -For file settings, store the relative url of the file in your `defaults.php` config file. The url could be relative to your `public` folder and make sure you have the specified file in the specified path. - -```php -// config/defaults.php -... -'logo' => 'img/logo.png', -... -``` - -When using the file setting, you can use Laravel's `asset()` helper method to get the full url of the file. - - -```php -$logo_url = asset(get_setting('logo')); -``` - -To set a file setting, use the `set_file_setting` helper method to set a file setting from the current request. - -## Model based settings - -For settings that refer to Models, you should use the slug or another uniquely identifiable value rather than the id of the Model as the id can potentially change when deployed to production. - -```php -// config/defaults.php -... -'terms_page' => 'terms-and-conditions', -... -``` - -Tou use the model based setting, you can query the model. - -```php -$terms_page = App\Models\Page::where('slug', get_setting('terms_page'))->first(); - -if ($terms_page) { - // model exists - // do stuff you want -} -``` - - diff --git a/docs/basic-usage/defining-settings.md b/docs/basic-usage/defining-settings.md new file mode 100644 index 0000000..0fdb366 --- /dev/null +++ b/docs/basic-usage/defining-settings.md @@ -0,0 +1,207 @@ +--- +title: Defining settings +sidebar_position: 1 +--- + +A setting has a key, a default, and a [stencil](https://github.com/Javaabu/stencil) field that gives its input, its label and its validation. You define all three in one place, and the package uses them everywhere: to read the value, to build the form, to validate and to cast. + +```php +use Javaabu\Settings\Definitions\Setting; + +Setting::make('site_name') + ->default(config('app.name')) + ->label(__('Site name')) + ->required() + ->maxLength(255) + ->thaana(); +``` + +# The field + +Without `field()`, a setting is a `TextInput`. Give it another stencil field by class; the field is named after the setting: + +```php +use Javaabu\Stencil\Fields\Checkbox; +use Javaabu\Stencil\Fields\CheckboxList; +use Javaabu\Stencil\Fields\ImageUpload; +use Javaabu\Stencil\Fields\Select; +use Javaabu\Stencil\Fields\Textarea; + +Setting::make('registration_open')->default(true)->field(Checkbox::class)->label(__('Registration is open')); + +Setting::make('theme')->default('light')->field(Select::class)->options([ + 'light' => __('Light'), + 'dark' => __('Dark'), +])->required(); + +Setting::make('seo_description')->field(Textarea::class)->label(__('Description'))->thaana(); + +Setting::make('featured_categories')->default(['books'])->field(CheckboxList::class)->options($categories); + +Setting::make('logo')->default('img/logo.png')->field(ImageUpload::class)->label(__('Logo')); +``` + +Any method the setting doesn't have is passed to the field, in order, whenever a field is made: `label()`, `help()`, `required()`, `maxLength()`, `options()`, `integer()`, `email()`, `thaana()`, `visible()`, `live()`, `rules()`… See stencil's [Fields](https://github.com/Javaabu/stencil/blob/main/docs/basic-usage/fields.md) for all of them. You can call them before or after `field()`. + +`field()` also takes a field named after the setting, or a closure given the key: + +```php +Setting::make('per_page')->field(TextInput::make('per_page')->integer()); +Setting::make('per_page')->field(fn (string $key) => TextInput::make($key)->integer()); +``` + +A field with another name throws a `SettingsException`. + +# Defaults + +`default()` is the value when nothing is stored. A closure is called each time the default is needed, so it can read config that changes: + +```php +Setting::make('app_title')->default(fn () => config('app.name')); +``` + +When a setting has no default (or it's `null`), `config('defaults.{key}')` is used, as in v1, and then the default you pass to `get()`. + +# Casts + +Values come back as the field's type: + +| Field | Value | +| --- | --- | +| `Checkbox` | `bool` | +| `TextInput` with `integer()` | `int` | +| `TextInput` with `numeric()` | `float` | +| `CheckboxList`, `Select` with `multiple()`, `FileUpload` with `multiple()` | `array` | +| anything else | as stored (a string for text inputs) | + +Set it yourself with `cast()`: `string`, `integer`, `float`, `boolean`, `array`, a backed enum class, or a closure. + +```php +use App\Enums\Themes; + +Setting::make('theme')->default(Themes::LIGHT)->cast(Themes::class)->field(Select::class)->options(Themes::class); + +settings('theme'); // Themes::LIGHT +``` + +Enums are stored as their values. + +# Encrypted settings + +API keys and passwords: + +```php +Setting::make('sms_api_key')->encrypted()->password()->label(__('API key')); +``` + +The value is encrypted with the app key in the store, and decrypted when you read it. On the settings page the field is always empty (the value never reaches the browser), with the help text "Leave blank to keep the current value."; leaving it empty keeps the value. The [activity log](../advanced-usage/activity-log.md) records that it changed, not the value. + +:::warning + +Don't add `required()` to an encrypted setting: its input is empty every time the page opens. + +::: + +# Pages + +Settings are grouped into pages. Each page has its own URL, form and permission. Extend `SettingsPage` and return the settings from `definition()`: + +```php +default(config('app.name'))->label(__('Site name'))->required()->thaana(), + + Section::make(__('Search engines')) + ->description(__('How the site appears in search results')) + ->columns(2) + ->schema([ + Setting::make('seo_description')->field(Textarea::class)->label(__('Description'))->thaana(), + Setting::make('seo_keywords')->label(__('Keywords')), + ]), + ]; + } +} +``` + +| Property or method | Default | What | +| --- | --- | --- | +| `protected ?string $key` / `getKey()` | the class name without `Settings`, in kebab case (`general`) | The page's URL segment | +| `getLabel()` | the key as a headline | The tab label | +| `getDescription()` | `null` | Shown under the heading | +| `getIcon()` | `null` | An icon name for the tab | +| `protected ?string $permission` / `getPermission()` | `null`: `edit_settings` | The permission that edits the page. See [Permissions](../advanced-usage/permissions-and-policies.md) | +| `protected int $order` / `getOrder()` | `0` | Pages are listed by order, then as registered | +| `isVisible()` | `true` | Return `false` to hide the page (it returns 404) | + +Register pages in `config/settings.php` (`'pages' => [GeneralSettings::class]`) or with `Settings::page(GeneralSettings::class)` in a service provider's `boot()`. + +## Sections + +`Section::make($heading)` groups settings on a page, rendered as a stencil section (a card). It takes `description()`, and passes anything else (`columns()`, `columnSpanFull()`, …) to the stencil section. Sections can be nested. + +A page's schema holds `Setting`s and `Section`s only. Anything else throws a `SettingsException`. + +## Inline pages + +For a page or two, register it from a service provider without a class. Pass closures so strings are translated in each request's locale: + +```php +use Javaabu\Settings\Definitions\Setting; +use Javaabu\Settings\Facades\Settings; + +public function boot(): void +{ + Settings::page('mail') + ->label(fn () => __('Mail')) + ->description(fn () => __('Where emails come from')) + ->icon('envelope') + ->permission('edit_mail_settings') + ->order(10) + ->schema(fn () => [ + Setting::make('mail_from_address')->default('info@example.com')->label(__('From address'))->email()->required(), + Setting::make('mail_from_name')->default(config('app.name'))->label(__('From name'))->thaana(), + ]); +} +``` + +`Settings::page('mail')` returns the same page when called again. + +# Settings without a page + +Settings that admins don't edit can still have a default, a cast and typed access: + +```php +Settings::define([ + Setting::make('skeleton_version')->default('9.0.0'), + Setting::make('max_upload_size')->default(2048)->cast('integer'), +]); +``` + +# Rules + +- A key is defined once: the same key on two pages throws a `SettingsException` when settings are first read. +- Use plain keys (`seo_description`), not dots. +- Every label, help text and option goes through `__()`. Add `->thaana()` to text settings that take Dhivehi. +- Put validation on the field (`required()`, `maxLength()`, `rules([...])`), never in a controller. diff --git a/docs/basic-usage/reading-and-writing.md b/docs/basic-usage/reading-and-writing.md new file mode 100644 index 0000000..37d87a9 --- /dev/null +++ b/docs/basic-usage/reading-and-writing.md @@ -0,0 +1,119 @@ +--- +title: Reading and writing settings +sidebar_position: 2 +--- + +# Reading + +```php +settings('site_name'); // the value, or its default +settings('per_page', 15); // with a fallback for a setting that has no default +Settings::get('site_name'); // the same, through the facade +get_setting('site_name'); // the v1 helper, still there +``` + +A setting's value is, in order: + +1. its stored value (even `null`, if that's what was saved), +2. its definition's `default()`, +3. `config('defaults.{key}')`, +4. the default you pass. + +Values are [cast](defining-settings.md#casts) on the way out. + +## Typed getters + +When you need a type, ask for it. They throw a `SettingsException` when the value is another type, so a misconfigured setting fails loudly instead of misbehaving: + +```php +settings()->string('site_name'); +settings()->integer('per_page'); +settings()->float('vat_rate'); // integers are returned as floats +settings()->boolean('registration_open'); +settings()->array('featured_categories'); + +settings()->integer('per_page', 20); // with a fallback +``` + +## Everything else + +| Method | Returns | +| --- | --- | +| `Settings::all()` | Every setting: the defined ones, the stored ones and the ones in `config('defaults')`, keyed by key | +| `Settings::only(['site_name', 'per_page'])` | Those settings | +| `Settings::has('site_name')` | Whether it has a stored value | +| `Settings::default('site_name')` / `default_setting('site_name')` | Its default | +| `Settings::url('logo')` | The URL of a file setting (see below) | +| `Settings::translate('site_name', 'dv')` | A [translatable setting](../advanced-usage/translatable-settings.md) in a locale | +| `Settings::definition('site_name')` | Its `Setting` definition, or `null` | +| `Settings::pages()` | The registered pages, in order | + +In Blade: + +```blade +{{ settings('site_name') }} +{{ settings('site_name') }} +``` + +# Writing + +```php +Settings::set('site_name', 'ދިވެހިރާއްޖެ'); +Settings::set(['site_name' => 'ދިވެހިރާއްޖެ', 'per_page' => 25]); +settings(['site_name' => 'ދިވެހިރާއްޖެ']); + +Settings::forget('site_name'); // back to the default +Settings::forget(['site_name', 'per_page']); +Settings::flush(); // remove every stored value +``` + +Writes go straight to the store, then: + +- the cached values are dropped and a new version is put in the cache, so other processes and [queue workers](../advanced-usage/queue-workers-and-octane.md) reload them; +- `Javaabu\Settings\Events\SettingsUpdated` is fired, if a value changed. + +`set()` and `forget()` take the user who made the change as `causer` (the authenticated user by default), for the [activity log](../advanced-usage/activity-log.md): + +```php +Settings::set($values, causer: $request->user('web_admin')); +``` + +Enums are stored as their values and encrypted settings are encrypted. Values must be JSON-serializable. + +## The SettingsUpdated event + +```php +use Javaabu\Settings\Events\SettingsUpdated; + +Event::listen(function (SettingsUpdated $event) { + if ($event->changed('maintenance_message')) { + Cache::forget('banner'); + } +}); +``` + +| Property or method | What | +| --- | --- | +| `$event->old_settings` | The values before, keyed by key | +| `$event->new_settings` | The values after | +| `$event->causer` | The user model, or `null` | +| `$event->changes()` | Only the keys that changed, with their new values | +| `$event->changed('key')` | Whether that key changed | + +# File settings + +A setting with a `FileUpload` or `ImageUpload` field stores a path. The settings page uploads files to `config('settings.disk')` (`public` by default) in `config('settings.directory')` (`settings`), and deletes the old file when a new one replaces it. + +`Settings::url('logo')` gives the URL: the disk's URL for an upload, `asset()` for a default path such as `img/logo.png` (a file in `public/`), and the value itself for a full URL. + +The v1 helpers are still there: `set_file_setting('logo')` saves the file uploaded under that name in the current request (into `public/storage/uploads`), and `reset_file_setting('logo')` deletes it from the public disk and resets the setting. + +# Model settings + +For settings that point at a model, store something that doesn't change between environments, such as a slug, rather than an id: + +```php +Setting::make('terms_page')->default('terms-and-conditions')->field(Select::class)->options(fn () => Page::pluck('title', 'slug')); + +$terms = Page::where('slug', settings('terms_page'))->first(); +``` diff --git a/docs/basic-usage/settings-pages.md b/docs/basic-usage/settings-pages.md new file mode 100644 index 0000000..ef2f222 --- /dev/null +++ b/docs/basic-usage/settings-pages.md @@ -0,0 +1,158 @@ +--- +title: Settings pages +sidebar_position: 3 +--- + +Every registered page gets a form, validation, saving and a page, in Blade or Inertia React. You only define the settings. + +# Routes + +```php +// routes/web.php +Route::settings(); +``` + +| Route | Name | What | +| --- | --- | --- | +| `GET /settings` | `settings.index` | Redirects to the first page the user can see (JSON: the pages) | +| `GET /settings/{settings_page}` | `settings.edit` | The page's form (JSON: the page and its values) | +| `PATCH /settings/{settings_page}` | `settings.update` | Validates and saves, then redirects back with a "Settings saved." status (JSON: the message and the values) | + +`Route::settings()` takes options: + +| Option | Default | What | +| --- | --- | --- | +| `prefix` | `settings` | The URL prefix | +| `name` | `settings` | The route name prefix (`admin.settings` gives `admin.settings.edit`) | +| `guard` | `null` (the default guard) | The guard users are authenticated with; permissions are checked for its user | +| `middleware` | `['auth']`, or `["auth:{guard}"]` with a guard | The route middleware. Put the routes in your `web` group (or one of your own) for sessions | +| `controller` | `config('settings.controllers.settings')` | See [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md) | + +```php +Route::middleware('web')->group(function () { + Route::settings(['guard' => 'web_admin', 'prefix' => 'admin/settings', 'name' => 'admin.settings']); +}); +``` + +Link to a page with `route('settings.edit', 'general')`. + +# What the page does + +- Tabs link to every page the user may see (when there's more than one). +- The form has the page's settings, filled with their current values (old input after a failed save). File settings show the current file; encrypted settings are empty. +- Saving validates with the fields' rules, for the fields that are visible, and stores only the page's settings: other input is ignored. +- Users who can view but not edit see the form disabled, without a save button, and a notice. +- Requests that expect JSON get JSON, and `422` with the errors when validation fails. + +[Live fields](https://github.com/Javaabu/stencil/blob/main/docs/basic-usage/live-fields.md) (`->live()`, closures with `$get`) work: add stencil's route once. Only users who may edit the page can re-evaluate its form. + +```php +Route::stencil()->middleware(['web', 'auth']); +``` + +# Blade + +The page is `settings::settings.edit`. It extends `config('settings.layout')` (`layouts.app`) and fills `config('settings.layout_section')` (`content`): + +```blade +{{-- resources/views/layouts/app.blade.php --}} + + + + @manifestStyles + + + @yield('content') + @manifestScripts + + +``` + +It gets these variables, if you [publish and change it](../advanced-usage/overriding-controllers-and-views.md): + +| Variable | What | +| --- | --- | +| `$form` | The page's `SettingsForm` (echo it: `{{ $form }}`) | +| `$page` | `key`, `label`, `description`, `icon`, `url`, `can_update` | +| `$pages` | The pages the user can see, each as `$page` plus `active` | +| `$status` | The message after saving, or `null` | +| `$strings` | `title`, `read_only`, `pages`, translated | +| `$layout`, `$layout_section` | From the config | + +# Inertia React + +With `'views' => 'inertia'`, the page is `settings::Settings/Edit`, shipped through javaabu/manifest. It renders the form with stencil's ``. The props are the variables above, with `form` as stencil's JSON schema. + +```tsx +// resources/js/app.tsx: give package pages your layout +import { createInertiaApp } from "@inertiajs/react" +import { resolvePage } from "@javaabu/manifest/inertia" +import { createRoot } from "react-dom/client" +import AdminLayout from "@/layouts/admin" + +createInertiaApp({ + resolve: async (name) => { + const page = await resolvePage(name, import.meta.glob("./pages/**/*.tsx")) + page.default.layout ??= (children: React.ReactNode) => {children} + return page + }, + setup({ el, App, props }) { + createRoot(el).render() + }, +}) +``` + +Override the page itself by copying it to `resources/vendor/settings/js/Pages/Settings/Edit.tsx` (manifest looks there first). + +The components are also exported as `@javaabu/settings`, to build your own page: + +```tsx +import { SettingsNav, type SettingsEditProps } from "@javaabu/settings" +import { StencilForm } from "@javaabu/stencil" + +export default function Edit({ form, page, pages }: SettingsEditProps) { + return ( + + + + + ) +} +``` + +| Export | What | +| --- | --- | +| `SettingsEdit` | The page component | +| `SettingsNav` | Tabs linking to `pages` (nothing for a single page) | +| `SettingsEditProps`, `SettingsPageData`, `SettingsStrings` | The prop types | + +# Your own forms + +`SettingsForm` is a stencil form class, so you can use a page's form anywhere: + +```php +use Javaabu\Settings\Forms\SettingsForm; + +public function edit() +{ + return view('onboarding.settings', [ + 'form' => SettingsForm::forPage('general')->action(route('onboarding.settings.update')), + ]); +} + +public function update(Request $request) +{ + $values = SettingsForm::forPage('general')->saveSettings($request, $request->user()); + + return to_route('onboarding.done'); +} +``` + +| Method | What | +| --- | --- | +| `SettingsForm::forPage($page, ?string $guard = null)` | The form of a page (a key or a `SettingsPage`), filled with the current values | +| `saveSettings(Request\|array\|null $input = null, ?Model $causer = null)` | Validates, uploads files, stores the page's settings and returns what was stored | +| `currentValues()` | The values the form starts with | +| `page()` | The `SettingsPage` | + +Every other stencil `Form` method works too: `validate()`, `rules()`, `embedded()`, `render('json')`, `assertable()`. diff --git a/docs/basic-usage/testing.md b/docs/basic-usage/testing.md index 51a3ad4..4e67faf 100644 --- a/docs/basic-usage/testing.md +++ b/docs/basic-usage/testing.md @@ -1,59 +1,83 @@ --- title: Testing -sidebar_position: 3 +sidebar_position: 4 --- -:::info +# Faking settings -The `FakesSettings` trait is included by default in the [`javaabu/helpers`](https://github.com/Javaabu/helpers) `TestCase` class. So you don't need to separately include this trait if you are already extending that class for your tests. +`Settings::fake()` keeps values in memory for the rest of the test: nothing is read from or written to the store or the cache. -::: +```php +use Javaabu\Settings\Facades\Settings; -This package provides a trait called `FakesSettings` for faking settings and default values. +#[Test] +public function it_closes_registration(): void +{ + Settings::fake(['registration_open' => false]); -The trait provides the following methods: + $this->get('/register')->assertForbidden(); +} +``` -- **`setFakeDefaultSetting(string $setting, $value)`**: Fake a setting default to a given value -- **`setFakeSetting(string $setting, $value, $default = null)`**: Fake a setting to a given value and optionally set a default to the given setting. If a default is not provided, it would be set to `null`. Note that when you need to set both a default and a setting to the same setting key, you should call this method with a default instead of calling the `setFakeDefaultSetting` method separately. +Defaults still come from the definitions. Fake a default with `Settings::fakeDefault('per_page', 5)`, and go back to the real store with `Settings::stopFaking()`. -When faking settings, for settings that are not faked, it will return the default value for those settings. +# The FakesSettings trait -You can use the trait in your tests like so. +Kept from v1, for tests that use it: ```php -... use Javaabu\Settings\Testing\FakesSettings; -/** @test */ -public function it_can_fake_a_default_setting() +class InvoicesTest extends TestCase { - // faking a default setting - - $this->setFakeDefaultSetting('daily_limit', 20); - - $this->assertEquals(20, default_setting('daily_limit')); + use FakesSettings; + + #[Test] + public function it_charges_vat(): void + { + $this->setFakeSetting('vat_rate', 8); // the value; its default becomes null + $this->setFakeSetting('currency', 'MVR', 'USD'); // the value and its default + $this->setFakeDefaultSetting('per_page', 5); // only the default + $this->setFakeSettings(['site_name' => 'ދިވެހިރާއްޖެ']); // several values + + // ... + } } +``` -/** @test */ -public function it_can_fake_a_setting() -{ - // faking a setting value - - $this->setFakeSetting('app_name', 'fake name'); +The trait is a thin layer over `Settings::fake()` and `Settings::fakeDefault()`. - $this->assertEquals('fake name', get_setting('app_name')); -} +# Testing pages -/** @test */ -public function it_can_fake_a_setting_with_a_default() +Test settings pages through their routes, as users use them: + +```php +#[Test] +public function admins_can_change_the_site_name(): void { - // faking a setting value with a default - - $this->setFakeSetting('app_name', 'fake name', 'default name'); + $admin = User::factory()->create(); + $admin->givePermissionTo('edit_settings'); - $this->assertEquals('default name', default_setting('app_name')); - $this->assertEquals('fake name', get_setting('app_name')); + $this->actingAs($admin) + ->patch(route('settings.update', 'general'), ['site_name' => 'ދިވެހިރާއްޖެ', 'per_page' => 20]) + ->assertSessionHasNoErrors() + ->assertRedirect(route('settings.edit', 'general')); + + $this->assertEquals('ދިވެހިރާއްޖެ', settings('site_name')); } -... ``` +Or test a page's form without HTTP, with stencil's `AssertableForm`: + +```php +use Javaabu\Settings\Forms\SettingsForm; + +SettingsForm::forPage('general') + ->assertable() + ->assertHasField('site_name') + ->assertState('site_name', settings('site_name')) + ->assertFieldRequired('site_name') + ->assertInvalid(['site_name' => ''], 'site_name'); +``` + +Long-running processes: [Queue workers and Octane](../advanced-usage/queue-workers-and-octane.md#testing) shows how to test a worker across a change. diff --git a/docs/changelog.md b/docs/changelog.md index 64422f4..b756309 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,4 +3,4 @@ title: Changelog sidebar_position: 1.5 --- -All notable changes to this package are documented on [GitHub](https://github.com/javaabu/settings/blob/main/CHANGELOG.md) +All notable changes to this package are documented on [GitHub](https://github.com/Javaabu/settings/blob/main/CHANGELOG.md) diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index c5b675e..e8fb566 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -3,36 +3,116 @@ title: Installation & Setup sidebar_position: 1.2 --- -You can install the package via composer: - ```bash composer require javaabu/settings +php artisan migrate +``` + +The migration creates the `settings` table (a row per setting, the value as JSON). Nothing else is needed: settings are read from the database and cached with the app's cache store. + +# Define your settings + +Put each page of settings in a class: + +```php +default(config('app.name'))->label(__('Site name'))->required()->maxLength(255)->thaana(), + Setting::make('per_page')->default(20)->label(__('Items per page'))->integer()->minValue(1)->maxValue(100), + ]; + } +} +``` + +and register it in `config/settings.php`, or from a service provider: + +```php +// config/settings.php +'pages' => [ + App\Settings\GeneralSettings::class, +], +``` + +```php +// AppServiceProvider::boot() +use Javaabu\Settings\Facades\Settings; + +Settings::page(GeneralSettings::class); ``` -# Setting up the defaults config file +See [Defining settings](basic-usage/defining-settings.md) for every option. + +# Add the settings pages -You will need to setup a `config/defaults.php` config file with your default settings. You can do so by publishing the sample defaults config file that comes with this package. +Register the routes where you want them, with your middleware: + +```php +// routes/web.php +Route::settings(); // /settings, auth middleware, settings.* names + +// or, for an admin portal on its own guard +Route::middleware(['web'])->group(function () { + Route::settings(['guard' => 'web_admin', 'prefix' => 'admin/settings', 'name' => 'admin.settings']); +}); +``` + +Sync the permissions and give them to a role: ```bash -php artisan vendor:publish --provider="Javaabu\Settings\SettingsServiceProvider" --tag="settings-defaults" +php artisan permissions:sync ``` -Below is the default content of the sample config file. You can replace the values in the file with the settings that you want. +Users with `edit_settings` can now open `/settings`. See [Settings pages](basic-usage/settings-pages.md). + +## Blade + +The Blade page extends `layouts.app` and fills its `content` section. Change both in the config: ```php - 'layouts.admin', +'layout_section' => 'main', +``` -return [ - /** - * Default Settings - */ +The layout needs manifest's tags (`@manifestStyles` and `@manifestScripts`) or its automatic injection, as for any javaabu/forms page. - 'app_name' => env('APP_NAME', 'Laravel'), +## Inertia React - 'favicon' => 'favicon.ico', -]; +```bash +composer require inertiajs/inertia-laravel +``` +```php +// config/settings.php, or SETTINGS_VIEWS=inertia +'views' => 'inertia', ``` +The page is `settings::Settings/Edit`, resolved by manifest's Vite plugin. Install the npm packages the page needs (`php artisan manifest:check` lists them), and wrap it in your layout where you resolve pages. + +# Publishing +| Tag | What | +| --- | --- | +| `settings-config` | `config/settings.php` | +| `settings-migrations` | The migration. Call `Settings::ignoreMigrations()` in a service provider's `register()` after publishing it | +| `settings-views` | The Blade view, to `resources/views/vendor/settings` | +| `settings-translations` | `lang/vendor/settings` | +| `settings-defaults` | A sample `config/defaults.php`, the v1 way of setting defaults | +```bash +php artisan vendor:publish --tag=settings-config +``` diff --git a/docs/intro.md b/docs/intro.md index 5398b55..ac45232 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -5,16 +5,39 @@ sidebar_position: 1.0 # Settings -:::danger +[javaabu/settings](https://github.com/Javaabu/settings) keeps the settings of a Laravel app (the site name, items per page, the logo, an API key) that admins change without a deploy. -This package is currently under development. If anything works, that's a surprise. +- **Defined once.** A setting's key, default, input and validation live together, defined with [javaabu/stencil](https://github.com/Javaabu/stencil) fields: `Setting::make('per_page')->default(20)->integer()->minValue(1)`. +- **Settings pages for free.** Group settings into pages and sections, add `Route::settings()`, and you get a form, validation, saving and a page in Blade ([javaabu/forms](https://github.com/Javaabu/forms)) or Inertia React ([@javaabu/forms](https://github.com/Javaabu/react-forms)). +- **Typed.** Values come back as the field's type (integers, booleans, arrays, enums), with strict getters: `settings()->integer('per_page')`. +- **Fresh everywhere.** Values are cached, and queue workers and Octane reload them when they change, without a restart. +- **Permissions.** A policy and default permissions through [javaabu/permissions](https://github.com/Javaabu/permissions), for any guard and user model, with a permission per page if you want one. +- **Dhivehi.** Every string is translated, the pages work right to left, and Thaana fields work out of the box. Translatable settings come with [javaabu/translatable](https://github.com/Javaabu/translatable). -::: - -[Settings](https://github.com/javaabu/settings) provide a settings backend based on [`aluntro/l4-settings`](https://github.com/anlutro/laravel-settings). +```php +use Javaabu\Settings\Definitions\Section; +use Javaabu\Settings\Definitions\Setting; +use Javaabu\Settings\Definitions\SettingsPage; +use Javaabu\Stencil\Fields\Textarea; -Here's a quick example of what this package can offer. +class GeneralSettings extends SettingsPage +{ + public function definition(): array + { + return [ + Setting::make('site_name')->default(config('app.name'))->label(__('Site name'))->required()->thaana(), + Setting::make('per_page')->default(20)->label(__('Items per page'))->integer()->minValue(1), + Section::make(__('Search engines'))->schema([ + Setting::make('seo_description')->field(Textarea::class)->label(__('Description'))->thaana(), + ]), + ]; + } +} +``` ```php -$app_name = get_setting('app_name'); +settings('site_name'); // 'ދިވެހިރާއްޖެ' +settings()->integer('per_page'); // 20 ``` + +Start with [Installation & Setup](installation-and-setup.md), then [Defining settings](basic-usage/defining-settings.md). Coming from v1? Read [Upgrading](upgrading.md). diff --git a/docs/metadata.json b/docs/metadata.json index b506696..711b56b 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { "name": "Settings", - "description": "Laravel helper functions for use in everywhere on a project", - "version": "1" + "description": "Settings for Laravel defined once with javaabu/stencil: typed access, automatic settings pages in Blade or Inertia React, and queue workers that pick up changes", + "version": "2" } diff --git a/docs/questions-and-security.md b/docs/questions-and-security.md index 82c29b8..375937c 100644 --- a/docs/questions-and-security.md +++ b/docs/questions-and-security.md @@ -3,6 +3,6 @@ title: Questions and Security sidebar_position: 1.4 --- -Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the media library? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible. +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. diff --git a/docs/requirements.md b/docs/requirements.md index 73bd6c0..f0b95f1 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -3,7 +3,16 @@ title: Requirements sidebar_position: 1.1 --- -This package requires the following: +- PHP 8.4 or 8.5 +- Laravel 12 or 13 +- [javaabu/stencil](https://github.com/Javaabu/stencil) v1, [javaabu/permissions](https://github.com/Javaabu/permissions) v3, [javaabu/forms](https://github.com/Javaabu/forms) v2 and [javaabu/manifest](https://github.com/Javaabu/manifest) v1, installed with this package +- For the settings pages: Vite with manifest's plugin. The Inertia React page also needs `@javaabu/forms`, `@javaabu/onu`, React 19 and `@inertiajs/react` 2 or 3 in the app (`php artisan manifest:check` lists them). +- A cache store every process shares (Redis, Memcached, database or file) when you run queue workers or Octane, so they see changed settings. See [Queue workers and Octane](advanced-usage/queue-workers-and-octane.md). -- Laravel 9.0 or higher -- PHP 8.1 or higher +Optional: + +| Package | For | +| --- | --- | +| `inertiajs/inertia-laravel` ^2.0 | The Inertia React page (`settings.views` = `inertia`). See [Settings pages](basic-usage/settings-pages.md). | +| `javaabu/translatable` ^3.0 | A value per language for translatable settings. See [Translatable settings](advanced-usage/translatable-settings.md). | +| `spatie/laravel-activitylog` ^4.10 | A log of every change. See [Activity log](advanced-usage/activity-log.md). | diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..36b951c --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,80 @@ +--- +title: Upgrading from v1 +sidebar_position: 1.25 +--- + +v2 keeps its own store instead of [anlutro/l4-settings](https://github.com/anlutro/laravel-settings), defines settings with [javaabu/stencil](https://github.com/Javaabu/stencil), and adds settings pages. Code that reads settings with `get_setting()` keeps working. + +# What still works + +- `get_setting()`, `default_setting()`, `set_file_setting()`, `reset_file_setting()` and `remove_prefix()`. +- `config/defaults.php`: settings without a default in a definition fall back to it. +- `Javaabu\Settings\Testing\FakesSettings`: `setFakeSetting()` and `setFakeDefaultSetting()`. +- `Javaabu\Settings\Events\SettingsUpdated` with `$old_settings`, `$new_settings` and `$causer`, and the `settings` activity log entry. + +# Steps + +## 1. Update the requirements + +PHP 8.4, Laravel 12 or 13. Then: + +```bash +composer require javaabu/settings:^2.0 +php artisan migrate +``` + +The migration creates the `settings` table. anlutro's `persistant_settings` table (or `storage/settings.json`) is left alone. + +## Move the values + +```bash +php artisan settings:import-v1 # from the persistant_settings table +php artisan settings:import-v1 --table=my_settings --connection=mysql +php artisan settings:import-v1 --from=json # from storage/settings.json +php artisan settings:import-v1 --from=json --path=/path/to/settings.json +``` + +Settings that already have a value in v2 are skipped unless you pass `--force`. Values are cast by their definitions when they have one (v1 stored everything as strings), so define your settings first, then import. Dotted keys (`featured.0`) are imported as arrays. + +## 2. Replace the `Setting` facade + +anlutro's facade is gone: + +| v1 | v2 | +| --- | --- | +| `use anlutro\LaravelSettings\Facades\Setting;` | `use Javaabu\Settings\Facades\Settings;` | +| `Setting::get('key', $default)` | `Settings::get('key', $default)` or `settings('key', $default)` | +| `Setting::set('key', $value); Setting::save();` | `Settings::set('key', $value);` (saved straight away) | +| `Setting::has('key')` | `Settings::has('key')` | +| `Setting::forget('key'); Setting::save();` | `Settings::forget('key');` | +| `Setting::all()` | `Settings::all()` | +| `config('settings.store')` (anlutro's `json`/`database`) | `config('settings.store')` (`database`, `json`, `array`) in the new [config file](advanced-usage/configuration.md) | + +anlutro's `config/settings.php` has the same name as v2's. Delete it (or publish v2's with `--tag=settings-config --force`). + +## 3. Define your settings (optional, recommended) + +Move each `config/defaults.php` entry that admins edit into a [definition](basic-usage/defining-settings.md), with its field and rules, and drop your hand-written settings controller and views for [`Route::settings()`](basic-usage/settings-pages.md): + +```php +// v1: config/defaults.php +'per_page' => 20, + +// v1: a controller validating and saving it +$request->validate(['per_page' => ['required', 'integer', 'min:1']]); +Setting::set('per_page', $request->input('per_page')); +Setting::save(); + +// v2: one definition +Setting::make('per_page')->default(20)->label(__('Items per page'))->required()->integer()->minValue(1); +``` + +## 4. Check what changed + +- **Types.** Values keep their type (v1 returned strings from the database store). A defined setting is cast by its field: `per_page` is `20`, not `"20"`. +- **`SettingsUpdated::$causer`** is any `Illuminate\Database\Eloquent\Model` (v1 typed it as `App\Models\User`), and defaults to the authenticated user when you don't pass one. +- **Activity log** is optional: install `spatie/laravel-activitylog` yourself if you used it. It's no longer a dependency. +- **`FakesSettings`** no longer mocks a facade: it fakes values in memory with `Settings::fake()`. Tests that mocked `anlutro\LaravelSettings\Facades\Setting` directly need `Settings::fake([...])` instead. +- **`EventServiceProvider`** (`Javaabu\Settings\Providers\EventServiceProvider`) is gone; the listener is registered by the main provider. +- **Long-running workers** reload changed settings by themselves. Remove any `queue:restart` you ran after changing settings. +- **Caching.** Values are cached in the app's cache store. Use a shared store (Redis, database, file) when you run queue workers. diff --git a/lang/dv.json b/lang/dv.json new file mode 100644 index 0000000..9b435d8 --- /dev/null +++ b/lang/dv.json @@ -0,0 +1,10 @@ +{ + "Settings": "ސެޓިންގްސް", + "Settings pages": "ސެޓިންގްސް ސަފްޙާތައް", + "Save settings": "ސެޓިންގްސް ސޭވްކުރޭ", + "Settings saved.": "ސެޓިންގްސް ސޭވްކުރެވިއްޖެ.", + "You can view these settings but not change them.": "ތިބާއަށް މި ސެޓިންގްސް ބެލޭނެ، ނަމަވެސް ބަދަލެއް ނުކުރެވޭނެ.", + "Leave blank to keep the current value.": "މިހާރުގެ އަގު ބަދަލު ނުކުރަން ބޭނުންނަމަ ހުސްކޮށް ދޫކޮށްލައްވާ.", + "View settings": "ސެޓިންގްސް ބެލުން", + "Edit settings": "ސެޓިންގްސް ބަދަލުކުރުން" +} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..b6879e1 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,10 @@ +{ + "Settings": "Settings", + "Settings pages": "Settings pages", + "Save settings": "Save settings", + "Settings saved.": "Settings saved.", + "You can view these settings but not change them.": "You can view these settings but not change them.", + "Leave blank to keep the current value.": "Leave blank to keep the current value.", + "View settings": "View settings", + "Edit settings": "Edit settings" +} diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..00118af --- /dev/null +++ b/llms.txt @@ -0,0 +1,44 @@ +# javaabu/settings + +> Settings for Laravel, defined once with javaabu/stencil: a setting's key, default, input and validation in one place (Setting::make('per_page')->default(20)->integer()). Settings are grouped into pages and sections; Route::settings() gives each page a form, validation, saving and a page in Blade (javaabu/forms) or Inertia React (@javaabu/forms, shipped through javaabu/manifest). Values are typed, cached, and reloaded by queue workers and Octane when they change. A policy and permissions come from javaabu/permissions; translatable settings from javaabu/translatable; an activity log from spatie/laravel-activitylog. + +Paths are relative to this file. In a project, that's vendor/javaabu/settings/. + +## Rules + +- Define every setting with `Javaabu\Settings\Definitions\Setting::make('key')`, on a `SettingsPage` class (`definition()`), an inline page (`Settings::page('mail')->schema(fn () => [...])`) or with `Settings::define([...])` for settings without a page. Don't add new keys to `config/defaults.php`; that's the v1 fallback. +- Put the default, the field and the validation on the setting: `->default(20)->field(TextInput::class)->integer()->minValue(1)->required()`. Methods the setting doesn't have go to the stencil field. Never validate settings in a controller. +- Read with `settings('key')` (or `Settings::get()`, or v1's `get_setting()`); use the typed getters (`settings()->integer('per_page')`, `string()`, `boolean()`, `float()`, `array()`) where the type matters. Don't cast by hand: values come back as the field's type, enums with `->cast(Enum::class)`. +- Write with `Settings::set([...])` (saved straight away, no `save()`). Don't write to the `settings` table or the cache directly: writes bump the version that workers check. +- Register the settings routes yourself with `Route::settings([...])` in the app's route group; pass `guard`, `prefix` and `name` for an admin portal. They aren't loaded automatically. Add `Route::stencil()` once if a setting uses `->live()`. +- Permissions are `view_settings` and `edit_settings`, plus each page's own `$permission`. Run `php artisan permissions:sync`. Override the policy with `config('permissions.policies')` or `Gate::policy(SettingsPage::class, ...)`. +- Secrets: `->encrypted()`. They're never sent to the browser; an empty input keeps the value. Don't add `required()` to them. +- Files: `->field(ImageUpload::class)` stores a path on `config('settings.disk')`; show it with `settings()->url('logo')`. +- Use a cache store every process shares (Redis, database, file) when running queue workers or Octane; never tell users to restart workers after changing settings. +- Labels, help texts, page labels and options go through `__()`. Add `->thaana()` to text settings that take Dhivehi; `->translatable()` for a value per language. +- In tests, use `Settings::fake([...])` or the `FakesSettings` trait (`setFakeSetting()`); don't mock the facade. + +## Docs + +- [Introduction](docs/intro.md): what the package does +- [Requirements](docs/requirements.md): PHP, Laravel, the Javaabu packages and the optional ones +- [Installation & Setup](docs/installation-and-setup.md): installing, defining a page, the routes, Blade and Inertia, publishing +- [Upgrading from v1](docs/upgrading.md): what still works, moving values from anlutro/l4-settings, replacing the Setting facade +- [AI assistants](docs/ai-assistants.md): Laravel Boost and these docs +- [Defining settings](docs/basic-usage/defining-settings.md): Setting, fields, defaults, casts, encrypted settings, pages, sections, inline pages, settings without a page +- [Reading and writing](docs/basic-usage/reading-and-writing.md): settings(), typed getters, set and forget, the SettingsUpdated event, file and model settings +- [Settings pages](docs/basic-usage/settings-pages.md): Route::settings() and its options, what the page does, Blade, Inertia React, SettingsForm in your own controllers +- [Testing](docs/basic-usage/testing.md): Settings::fake(), FakesSettings, testing pages and forms +- [Configuration](docs/advanced-usage/configuration.md): every config option +- [Stores and caching](docs/advanced-usage/stores-and-caching.md): database, json and array stores, custom stores, how the cache and versions work +- [Queue workers and Octane](docs/advanced-usage/queue-workers-and-octane.md): how workers pick up changes, turning it off, testing it +- [Permissions and policies](docs/advanced-usage/permissions-and-policies.md): view_settings, edit_settings, per-page permissions, the policy, guards +- [Overriding controllers and views](docs/advanced-usage/overriding-controllers-and-views.md): the controller and its hooks, the Blade view, the React page +- [Activity log](docs/advanced-usage/activity-log.md): logging changes with spatie/laravel-activitylog +- [Translatable settings](docs/advanced-usage/translatable-settings.md): a value per language with javaabu/translatable +- [Dhivehi and RTL](docs/advanced-usage/dhivehi-and-rtl.md): strings, RTL, Thaana, translations to review +- [API reference](docs/advanced-usage/api-reference.md): every helper, class, method and JavaScript export +- [Questions and Security](docs/questions-and-security.md): where to get help +- [Changelog](docs/changelog.md): where changes are listed +- [About Us](docs/about-us.md): Javaabu +- [Contributing to the docs](docs/contributing-to-docs.md): how the docs are built diff --git a/package.json b/package.json new file mode 100644 index 0000000..8592e26 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "javaabu-settings-dev", + "private": true, + "description": "Development tooling for the Inertia React pages of javaabu/settings. Apps don't install this; they get the pages through javaabu/manifest.", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "check": "pnpm typecheck && pnpm test" + }, + "devDependencies": { + "@inertiajs/react": "^2.0.0", + "@javaabu/forms": "github:Javaabu/react-forms#next-gen", + "@javaabu/onu": "^0.2.1", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": ["@javaabu/forms", "@javaabu/i18n", "esbuild"] + }, + "packageManager": "pnpm@10.28.0" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d5a979d..cf8b8f1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,16 +10,16 @@ tests + + + benchmark + + src - - - - - diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..44df8bf --- /dev/null +++ b/pint.json @@ -0,0 +1,20 @@ +{ + "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" + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..708b03e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4326 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@inertiajs/react': + specifier: ^2.0.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/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(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/onu': + specifier: ^0.2.1 + version: 0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/jest-dom': + specifier: ^6.6.0 + version: 6.10.0(@testing-library/dom@10.4.2) + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@types/react': + specifier: ^19.0.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.3.0(@types/react@19.3.0) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.3.1 + react-dom: + specifier: ^19.0.0 + version: 19.3.1(react@19.3.1) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(jsdom@26.1.0) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dnd-kit/abstract@0.5.3': + resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==} + + '@dnd-kit/collision@0.5.3': + resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==} + + '@dnd-kit/dom@0.5.3': + resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==} + + '@dnd-kit/geometry@0.5.3': + resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==} + + '@dnd-kit/helpers@0.5.3': + resolution: {integrity: sha512-fErbbjwKMJEdr7e+AMsXaZ6aQY4xPYBMK+nqybB34tE6/lQDLetZHsgLDKNVePsYIZCq/7uWMtlFjqysscWg5A==} + + '@dnd-kit/react@0.5.3': + resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.5.3': + resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@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 + + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + + '@internationalized/number@3.6.9': + resolution: {integrity: sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21': + resolution: {tarball: https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21} + 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 + + '@javaabu/onu@0.2.1': + resolution: {integrity: sha512-GE4takAQwNQqD9ZmQooH7KMBrLUoiR4UzJsDCGr6cLYigoLF22tjRD3CzrdVGVl+SAhz2jjwOozxzyvAyTNmxA==} + peerDependencies: + '@deck.gl/aggregation-layers': ^9.4.0 + '@deck.gl/core': ^9.4.0 + '@deck.gl/google-maps': ^9.4.0 + '@deck.gl/layers': ^9.4.0 + '@fontsource-variable/geist': ^5.3.0 + '@fontsource-variable/inter': ^5.3.0 + '@fontsource-variable/jetbrains-mono': ^5.3.0 + '@fontsource-variable/noto-sans-thaana': ^5.3.0 + '@fontsource-variable/source-serif-4': ^5.3.0 + '@fortawesome/fontawesome-svg-core': ^7.3.1 + '@fortawesome/react-fontawesome': ^3.5.0 + '@fullcalendar/core': ^6.1.21 + '@fullcalendar/daygrid': ^6.1.21 + '@fullcalendar/interaction': ^6.1.21 + '@fullcalendar/list': ^6.1.21 + '@fullcalendar/react': ^6.1.21 + '@fullcalendar/timegrid': ^6.1.21 + '@googlemaps/markerclusterer': ^2.6.2 + '@iconify/react': ^6.0.2 + '@tanstack/react-query': ^5.104.1 + '@tiptap/core': ^3.31.4 + '@tiptap/extension-image': ^3.31.4 + '@tiptap/extension-placeholder': ^3.31.4 + '@tiptap/extension-table': ^3.31.4 + '@tiptap/extension-text-align': ^3.31.4 + '@tiptap/pm': ^3.31.4 + '@tiptap/react': ^3.31.4 + '@tiptap/starter-kit': ^3.31.4 + '@tiptap/static-renderer': ^3.31.4 + '@tiptap/suggestion': ^3.31.4 + '@turf/turf': ^7.4.0 + '@vis.gl/react-google-maps': ^1.10.1 + barcode-detector: ^3.2.2 + media-chrome: ^4.19.3 + react: ^19.0.0 + react-dom: ^19.0.0 + react-easy-crop: ^6.2.4 + react-hook-form: ^7.89.0 + recharts: ^3.10.1 + shiki: ^4.5.0 + signature_pad: ^5.1.4 + tailwindcss: ^4.0.0 + terra-draw: ^1.37.0 + terra-draw-google-maps-adapter: ^1.7.0 + write-excel-file: ^4.1.1 + yet-another-react-lightbox: ^3.32.2 + peerDependenciesMeta: + '@deck.gl/aggregation-layers': + optional: true + '@deck.gl/core': + optional: true + '@deck.gl/google-maps': + optional: true + '@deck.gl/layers': + optional: true + '@fontsource-variable/geist': + optional: true + '@fontsource-variable/inter': + optional: true + '@fontsource-variable/jetbrains-mono': + optional: true + '@fontsource-variable/noto-sans-thaana': + optional: true + '@fontsource-variable/source-serif-4': + optional: true + '@fortawesome/fontawesome-svg-core': + optional: true + '@fortawesome/react-fontawesome': + optional: true + '@fullcalendar/core': + optional: true + '@fullcalendar/daygrid': + optional: true + '@fullcalendar/interaction': + optional: true + '@fullcalendar/list': + optional: true + '@fullcalendar/react': + optional: true + '@fullcalendar/timegrid': + optional: true + '@googlemaps/markerclusterer': + optional: true + '@iconify/react': + optional: true + '@tiptap/core': + optional: true + '@tiptap/extension-image': + optional: true + '@tiptap/extension-placeholder': + optional: true + '@tiptap/extension-table': + optional: true + '@tiptap/extension-text-align': + optional: true + '@tiptap/pm': + optional: true + '@tiptap/react': + optional: true + '@tiptap/starter-kit': + optional: true + '@tiptap/static-renderer': + optional: true + '@tiptap/suggestion': + optional: true + '@turf/turf': + optional: true + '@vis.gl/react-google-maps': + optional: true + barcode-detector: + optional: true + media-chrome: + optional: true + react-easy-crop: + optional: true + recharts: + optional: true + shiki: + optional: true + signature_pad: + optional: true + terra-draw: + optional: true + terra-draw-google-maps-adapter: + optional: true + write-excel-file: + optional: true + yet-another-react-lightbox: + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@preact/signals-core@1.14.5': + resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} + + '@radix-ui/react-accessible-icon@1.2.0': + resolution: {integrity: sha512-BB4+HHkFl3HipEAhm/mLhrpXV+2kRX7MvY4AaImUcQVeWQt5KhV1JEByNs8NGfJOcr95RcQGijt9+OrCo68xGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.3.1': + resolution: {integrity: sha512-lCZ6sbGS/7WO0j84yx4srIgOf0MRJ/sZ13RonADXxvKBym8xrxNCq5f6DibGcr38sIlwPk8/52uYdXeOPsbEDw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.2.1': + resolution: {integrity: sha512-MWC4N7RY+qsbNrFNo7yQ9ZbdoZEW+B5DtpWxJfUnVCqcQxxqqg3qis3RMPTDbCNaTIdHJSn5XDkl9ZdVWXknLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.17': + resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.2.0': + resolution: {integrity: sha512-gHEU+Lx8DlT/FYIjZQvvBJrFSwUngXTjh7FeL9QvJaNALDLez6pgDj2axCUihBjtPBy82LN0gNb25+sTcSXNmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.3.1': + resolution: {integrity: sha512-ssZOkfQNkFpYVmCPQlr978xSaU7moSrGXdwg4j2Xs2WlGl/1KsSZtfpK+WnLnx0Rwll1BAh6oUrAy/uR31SBvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.4.1': + resolution: {integrity: sha512-KWtwhh+98ML3seZ9vO9yRY2OwaQG90dCiDAxjpSNY2AaZ3Hik3B0dsk6m0ILywo/no3A9/SozadyPMNweVT8yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.2.1': + resolution: {integrity: sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.2.0': + resolution: {integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.4.1': + resolution: {integrity: sha512-Pddn3Z8lio3/ChxyXaEP3jHTTo1LTHCWwF8+mG1chQlWijP+ttVHBKwezrQTiV6Mj670ojvumqvuGh+mdv33iQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.3.1': + resolution: {integrity: sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.2.0': + resolution: {integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.2.1': + resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.2.1': + resolution: {integrity: sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.3.1': + resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.3.0': + resolution: {integrity: sha512-KnmrCM27m7Jo1R8S6cFDPrEeq20Jfn5dqde0SZrJr2lVXlj6/QZSUCyFoYh1d8ti0uUrMNK5Lx6dUDuINT+f4Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.2.1': + resolution: {integrity: sha512-4QooHxgmpCKRjj1Pft+hETKidYcrbPKcVwMGtk2Oa5DcWY2IH1fHuQt9LG6q5hUQBBV2MhQxSvtx6B/C1xfiTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.17': + resolution: {integrity: sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.2.1': + resolution: {integrity: sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.2.1': + resolution: {integrity: sha512-bRTYtS9JITV2dUYqYZb7+N5SooQBkZrNRejfmyrSKe3Y57aXhKgx//kBUP0kY7pEpsWR/U1AHsAJRzjwCrK3PA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.4.1': + resolution: {integrity: sha512-XPPZjkeyCSMNNvt0FyDv/PUauG7Ow4GwHQ/XZSwW36F/TS7rR4HFTsXH8ewLnXTOLn3x96/pmTPNQIVoTX/86g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.2.1': + resolution: {integrity: sha512-KtHdcgnaAK6rohQ/MMJoGYcbgSRJsIuGBdHrFeTB1/9SaKlDs14oSOniW6BL6M43HqvgCO3u4WuVdFLkuc6sqw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.2.1': + resolution: {integrity: sha512-vu4+ZoJAaFjdI7l1lDSZRs+4dEv83Jmu7lyzajPrAeQzX74y84n0rUNWbWSgN9XB5NE8IY86Z02kGH6oRrzR+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.3.1': + resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.4.0': + resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.2.1': + resolution: {integrity: sha512-yQBiM6J3YM8Ums1orkEFpbznK47geKxa0igikzcaS1xHgdba3DdAQBsGbCTt5BVfR8zhEvgxQ0poGcINaD3lvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.5.1': + resolution: {integrity: sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.2.1': + resolution: {integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.4.0': + resolution: {integrity: sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.4.1': + resolution: {integrity: sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.2.0': + resolution: {integrity: sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.6.1': + resolution: {integrity: sha512-IRNRR90moPqxfwCJ0XbMJIxo70W0/1ZExT/sNCxEHMaPx0SF9HoQrcPEC75+H28cFKa0b+KH63anfLYJmwKacw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.4.1': + resolution: {integrity: sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.2.1': + resolution: {integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.3.1': + resolution: {integrity: sha512-iQvL0J7XK4HtJNGkkWMyYrboNseI7vtotFeUkIno8YNxVA9oM7B6//fh0Mc6TSKI96+zioLWi98lQLso88yJmQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.2.1': + resolution: {integrity: sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.2.1': + resolution: {integrity: sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.2.1': + resolution: {integrity: sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.4.1': + resolution: {integrity: sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.6': + resolution: {integrity: sha512-9cRullnQRv+kqnRmFlmh0WC3kAPuGmHiQ9VwsXdrYk//sBhgCKEttcpPEPgIQbyx6TGUmooVEHYTyohwz/lSJg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.5': + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.13': + resolution: {integrity: sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@react-types/shared@3.37.0': + resolution: {integrity: sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@rollup/rollup-android-arm-eabi@4.67.5': + resolution: {integrity: sha512-FdWPeulkH+FH+rBzyKBD59tuVN4tRKvmVilSJm0OcnoYsQTpd9UhD78OaPcsYqo2fX6hCwAG0YIjrDLoQ3pR3g==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.67.5': + resolution: {integrity: sha512-bmkDuBGnF+Ji8atWlXcNjxIC52ShCbdXPbW7Nz0lRoCFi1lrvsO0XobJY36kTj7duc1vOmGJeQ9vtVuGjnzihA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-android-x64@4.67.5': + resolution: {integrity: sha512-6KvVfnqPTqmbQ5YobMl9HYiSymAsCt8HlNeriDshwJQcveXwT+vIMW6wGL1RcJbXH9+r3BAJvjab5+rIgtSEqA==} + cpu: [x64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.67.5': + resolution: {integrity: sha512-SNamgdrY69oPdBrQV9fKGo6PSmKcVhyn0D0jnDmcqgRJvrxQGGBhtekOHTUJ7TCgkW0nE8XMH9v2CwXmjNIzVA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.67.5': + resolution: {integrity: sha512-HNdl34thiJtHDSglogjmAlQ296L8cc8zA7ODH65nnmY7E+jPP55WEbh8J5583rWFLMjxyYywxZuwPOwRvEi5tA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.67.5': + resolution: {integrity: sha512-WvZYtANNswfHzSNHaJAoG6Ipfc4SyZIDYuIjFdHv07xXxhHl+Xx8o+bNDWsxpOADFT2mKXcXVrfOG4OlWTLo8Q==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.67.5': + resolution: {integrity: sha512-EUU+Wgf9NyHekceC+DxAdm42f6bq2ezH/7DEb/JnCb8SAUhG2xph5BiVukxplWal2m5pP8CzJZE+FLwRopywjQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + resolution: {integrity: sha512-dgU1CvUuE2IkaJBAeuAbxpDdMHAdZBT5oth5u22DtuoMg4gXXYr2+Sm30RigPJG1F2VEoiGJUziGLuIh9+jPxA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + resolution: {integrity: sha512-rdgJcJig2VngYlcTCRQw2BA8kLWM6QwDFe9uLFo81lya0jPC1r8wZu92sAvTUIv48GWlkRVi6/fpsa5CoFSKoQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + resolution: {integrity: sha512-+jtCiQnrEFHWAUAcUpP7wppb1p6HKOgBP3mXx75cEYpDF2Y5MEqBky2HNz+osFuVx5uX44gnMTmbe5RJauzn6A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.67.5': + resolution: {integrity: sha512-heflX7ENhL8eHmLcUxDEHCSK8CKLyPprysb2JGoSTh5Ip7nHrzIRc+2USyxul3nxi98H8pGxJIEDEpURlS1X5A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + resolution: {integrity: sha512-rRtNU0zBfRHkJ8pscHN9qRtUYLq8Rao4EX7aVWvKcFmJUANkCCXFRU7ovNBEMhpEONyUEB/3DRVDXwDDguSfHA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.67.5': + resolution: {integrity: sha512-/6auyi9yj8MD/Q+8P+f2ay57jQ7a0j7Yp1JK86pecH6ZnRViQZ5GOjoRSjGArFeTSHFznRbM2QPQcBgDPyNMfQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + resolution: {integrity: sha512-Hsc0vlt+vPvqzUkHKffWKf3YE/yexrY+yeFf7IsUvDC6PV7YGCsA/gJvPa+WJdgxGJnwuoO1HmLkbahaLKabYQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + resolution: {integrity: sha512-Jx5TMMSPJwHY7F/JwPdS6nuBwny2ECRK+EsAJV5HZrfiddeSS6ZrohkLkFZm3rjqBUuQ4FPX5gGMtakUuQO6fQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + resolution: {integrity: sha512-fGWcq2lo/XCqAkBLOokg66D/z+MUouryB8i+QdU9RR6O5knG7rucc0WFPqWYL1Wk/wuiJouDfmyBxmZKRM9bag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + resolution: {integrity: sha512-JwO/Ys3jybc0UW3V5XO2CMnp0RhvoczDDQTgAEE1IctT+HRY3W5parIIWLUpmAK4gHSeeYJofm2iQPFA62K6ag==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + resolution: {integrity: sha512-vVD4iqel6J6A1xAJlb4mxEO9i1wOJ4/SkcKoDKAuK/wqEBPOA7/P5dDyZvNx46ocHwOUwzo3WjhTdKqET9kEAg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.67.5': + resolution: {integrity: sha512-HmEfNh5Cdd/NHxgqwMzKnjlvicQQA+D+w+vQXDJSV0x/aHiQSjVwh1Th5RoA7QsMieIaxibt/5iJq68BraTSUg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.67.5': + resolution: {integrity: sha512-NfmALpbe009nYzZ2QqlSW6nzbGddXpqk3nwualrIr+4QflQCTGMzt3QKK20yARiAnSV9fJdM5WNwIYUf31x3uA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.67.5': + resolution: {integrity: sha512-ngcE7Udpy/iJ3lexVhYcbpUV2OJIakRGus3snc6K52BnAAJetDBG1Ku5Eh3CECtnW3mpBYWg5VCD4tTaOXKOVQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.67.5': + resolution: {integrity: sha512-DZ01A2EdTYX8xVeGoGiWkJBRWDh+/BOflwGPVsBay43PDxcq8ez0xuovZnPnk1xjE3CdI0pdZXxsJp/Xjmf7uQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + resolution: {integrity: sha512-nJKiMKDO140DUCEcQZl+f4HiGPMBxrW2webjuWJ2E3qPeoruHf2zByml4TP52lA1zDi6L4I92D/5stxeA/6pig==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + resolution: {integrity: sha512-ykLkBCf+Kls24QmqM3IRf1T4sGMh0rXPyymtj+hXlWKPJnXbXraHqUUsKtOIxqLasjT1i7DhfQGAaZYeiHbSOw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.67.5': + resolution: {integrity: sha512-NiFPUFw8cHhkmBlL1k1eaz+5AXXm89rztj+DQdDuQ7VGPM0+ADFhezQ2KcBMF50UEFzGUr0+DVjVND743XwKWQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.67.5': + resolution: {integrity: sha512-Khj/vrKCpdbiAWYbJAeSy4D5iep+qwW/86lNoKvdleJYGxgbRE7h87+5d5T9D7gt8NcqhNFpreRgIg8T4bLzKQ==} + cpu: [x64] + os: [win32] + + '@sindresorhus/slugify@3.0.1': + resolution: {integrity: sha512-N1oPvN6sytRKZCskv0L+vSyvnUYi9JplPXZ90lT6EBsijjHrUdoXttb0Lna6pw1TWMe8vlajQnLDtU9KYHqtDg==} + engines: {node: '>=20'} + + '@sindresorhus/transliterate@2.3.1': + resolution: {integrity: sha512-gVaaGtKYMYAMmI8buULVH3A2TXVJ98QiwGwI7ddrWGuGidGC2uRt4FHs22+8iROJ0QTzju9CuMjlVsrvpqsdhA==} + engines: {node: '>=20'} + + '@swc/helpers@0.5.24': + resolution: {integrity: sha512-6tcXo4kA8HgGO7AVmawB57RYi/piBPYVHVcO6dXjX+SZw8GoHmi262sp4v2Ha2kNP9Cx84qei27yT+fjS1lxgg==} + + '@tanstack/query-core@5.105.3': + resolution: {integrity: sha512-v1rbNy7MB/4Akth7CQH+CerV13K7Ok9hAusEsfz4sCjDdu3/kOSJPoWfoB+YjdEX1eBFBWKNAGE5nqZCgdlHDw==} + + '@tanstack/react-query@5.105.3': + resolution: {integrity: sha512-dj5hGWZmwoc9NG4enoXuYbiYwb5FxgJYPVv06eUgicMTfEMTRXn08Xv9D/r1V8TfjAv+BpxdvZFzJ1h3hSWZcQ==} + peerDependencies: + '@types/react': ^18 || ^19 + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@9.3.1': + resolution: {integrity: sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' + + '@tanstack/react-virtual@3.14.26': + resolution: {integrity: sha512-HUDlfsrYH6ko5xH56L62Mcl/uzT+AGVNMHFuDNqDJMdZWc1AQa2oeVKNUvZ/RWBZQ4TYtyQerFrNvLnp39h65A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + + '@tanstack/table-core@9.3.1': + resolution: {integrity: sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ==} + engines: {node: '>=20'} + + '@tanstack/virtual-core@3.19.11': + resolution: {integrity: sha512-/5iHb0RHBvj8Di59FDs4ESNm45jls33aZAi4zccVXZjc/6ZvwQP6nzEYeT6weXjTf9E6/vrp2JhYWRyO+ZP13A==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@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/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==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.21.0: + resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + 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'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + 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-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + 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'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + 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'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + 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-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + 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'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + input-otp@1.5.0: + resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + laravel-precognition@1.0.2: + resolution: {integrity: sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==} + + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lucide-react@1.68.0: + resolution: {integrity: sha512-9i7NKWm2QlOprMhcwkhBOlIt9zB7cp2GIyFY+oMue0EsprolUFxva5rkdBbyfs4k1eejpa0JRnRgVOGiart+Xg==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + 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'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + + radix-ui@1.8.1: + resolution: {integrity: sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + react-aria-components@1.22.1: + resolution: {integrity: sha512-UTHaYHOaM71bOCjGPF50DcLywlrwAodE63IP64LB4kO+OizatQEL8TesVEOM8+TkBeSgHoflJ45388IlJfa7aw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.53.1: + resolution: {integrity: sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-day-picker@10.0.2: + resolution: {integrity: sha512-QetiKtZWE9Uaw/pM/SYNv6WPYOTRnSJ2puL0GgwkmUFMIwiY0dtjH1W4B7DOVXVNQM0ZOClGpH/heOfBSzWeiA==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-hook-form@7.91.0: + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-phone-number-input@3.4.18: + resolution: {integrity: sha512-EmPhJFSxQB7CTmLAlQfGAQQPF+WmJECU08gjyUt50zd2iESb4ETsm6z+BRfUEjH+TXct4EWvJvub+OztJxb/Cg==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-stately@3.51.0: + resolution: {integrity: sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + rollup@4.67.5: + resolution: {integrity: sha512-3Ve5VH1hacCVutdpbmu/htg4kz+JCOIWgy4Q3hjHPYGHeHeIElNHJ0LcJ3rCfIsd5G7qOl5k5mwpzGMj9kdHPA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + 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'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonner@2.0.8: + resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tailwindcss@4.3.4: + resolution: {integrity: sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.7: + resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.10': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@date-fns/tz@1.5.0': {} + + '@dnd-kit/abstract@0.5.3': + dependencies: + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/collision@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/dom@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/collision': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/geometry@0.5.3': + dependencies: + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/helpers@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/react@0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/state': 0.5.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + tslib: 2.9.1 + + '@dnd-kit/state@0.5.3': + dependencies: + '@preact/signals-core': 1.14.5 + tslib: 2.9.1 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@floating-ui/utils@0.2.12': {} + + '@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 + + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/number@3.6.9': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/string@3.2.10': + dependencies: + '@swc/helpers': 0.5.24 + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(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) + '@javaabu/onu': 0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4) + '@testing-library/react': 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(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 + + '@javaabu/onu@0.2.1(@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(tailwindcss@4.3.4)': + dependencies: + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/helpers': 0.5.3 + '@dnd-kit/react': 0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@internationalized/date': 3.12.4 + '@sindresorhus/slugify': 3.0.1 + '@tanstack/react-query': 5.105.3(@types/react@19.3.0)(react@19.3.1) + '@tanstack/react-table': 9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/react-virtual': 3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + date-fns: 4.4.0 + embla-carousel-react: 8.6.0(react@19.3.1) + input-otp: 1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + lucide-react: 1.68.0(@types/react@19.3.0)(react@19.3.1) + radix-ui: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-aria-components: 1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-day-picker: 10.0.2(@types/react@19.3.0)(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.1) + react-phone-number-input: 3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + sonner: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + tailwind-merge: 3.7.0 + tailwindcss: 4.3.4 + tw-animate-css: 1.4.0 + uqr: 0.1.3 + vaul: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@preact/signals-core@1.14.5': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.2.0': {} + + '@radix-ui/react-accessible-icon@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-accordion@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-alert-dialog@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-aspect-ratio@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-avatar@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-checkbox@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collapsible@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collection@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-context-menu@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-context@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-direction@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-dropdown-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-form@0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-hover-card@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-label@2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menubar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-navigation-menu@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-one-time-password-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-password-toggle-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popover@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popper@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-progress@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-radio-group@1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-roving-focus@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-scroll-area@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-select@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-separator@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slider@1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slot@1.5.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-switch@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tabs@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toast@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle-group@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toolbar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tooltip@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-escape-keydown@1.1.6(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-size@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-visually-hidden@1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/rect@1.1.3': {} + + '@react-types/shared@3.37.0(react@19.3.1)': + dependencies: + react: 19.3.1 + + '@rollup/rollup-android-arm-eabi@4.67.5': + optional: true + + '@rollup/rollup-android-arm64@4.67.5': + optional: true + + '@rollup/rollup-android-x64@4.67.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.67.5': + optional: true + + '@rollup/rollup-darwin-x64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.67.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.67.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.67.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.67.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.67.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.67.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.67.5': + optional: true + + '@sindresorhus/slugify@3.0.1': + dependencies: + '@sindresorhus/transliterate': 2.3.1 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@2.3.1': {} + + '@swc/helpers@0.5.24': {} + + '@tanstack/query-core@5.105.3': {} + + '@tanstack/react-query@5.105.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.105.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@tanstack/react-store@0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + '@tanstack/react-table@9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/table-core': 9.3.1 + react: 19.3.1 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-virtual@3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/virtual-core': 3.19.11 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@tanstack/store@0.11.2': {} + + '@tanstack/table-core@9.3.1': + dependencies: + '@tanstack/store': 0.11.2 + + '@tanstack/virtual-core@3.19.11': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.10 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.2)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.10 + '@testing-library/dom': 10.4.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.10': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.27 + + '@types/lodash@4.17.27': {} + + '@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 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.7)': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.7 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.9.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + 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 + + cac@6.7.14: {} + + 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 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + classnames@2.5.1: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + country-flag-icons@1.6.20: {} + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + date-fns@4.4.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + embla-carousel-react@8.6.0(react@19.3.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.3.1 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.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 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@5.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + 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 + + fsevents@2.3.3: + optional: true + + 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-nonce@1.0.1: {} + + 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 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + input-format@0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + input-otp@1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.22.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + laravel-precognition@1.0.2: + dependencies: + axios: 1.21.0 + lodash-es: 4.18.1 + transitivePeerDependencies: + - debug + - supports-color + + libphonenumber-js@1.13.15: {} + + lodash-es@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lucide-react@1.68.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + nwsapi@2.2.28: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-accessible-icon': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-accordion': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-alert-dialog': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-aspect-ratio': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-avatar': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-checkbox': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context-menu': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dropdown-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-form': 0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-hover-card': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menubar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-navigation-menu': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-one-time-password-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-password-toggle-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popover': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-progress': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-radio-group': 1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-scroll-area': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-select': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slider': 1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-switch': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tabs': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toast': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toolbar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tooltip': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.6(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + react-aria-components@1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + client-only: 0.0.1 + react: 19.3.1 + react-aria: 3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-aria@3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-phone-number-input@3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.20 + input-format: 0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + libphonenumber-js: 1.13.15 + prop-types: 15.8.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.1) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.1) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + react-stately@3.51.0(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + get-nonce: 1.0.1 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react@19.3.1: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + rollup@4.67.5: + dependencies: + '@types/estree': 1.0.10 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.67.5 + '@rollup/rollup-android-arm64': 4.67.5 + '@rollup/rollup-android-x64': 4.67.5 + '@rollup/rollup-darwin-arm64': 4.67.5 + '@rollup/rollup-darwin-x64': 4.67.5 + '@rollup/rollup-freebsd-arm64': 4.67.5 + '@rollup/rollup-freebsd-x64': 4.67.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.67.5 + '@rollup/rollup-linux-arm-musleabihf': 4.67.5 + '@rollup/rollup-linux-arm64-gnu': 4.67.5 + '@rollup/rollup-linux-arm64-musl': 4.67.5 + '@rollup/rollup-linux-loong64-gnu': 4.67.5 + '@rollup/rollup-linux-loong64-musl': 4.67.5 + '@rollup/rollup-linux-ppc64-gnu': 4.67.5 + '@rollup/rollup-linux-ppc64-musl': 4.67.5 + '@rollup/rollup-linux-riscv64-gnu': 4.67.5 + '@rollup/rollup-linux-riscv64-musl': 4.67.5 + '@rollup/rollup-linux-s390x-gnu': 4.67.5 + '@rollup/rollup-linux-x64-gnu': 4.67.5 + '@rollup/rollup-linux-x64-musl': 4.67.5 + '@rollup/rollup-openbsd-x64': 4.67.5 + '@rollup/rollup-openharmony-arm64': 4.67.5 + '@rollup/rollup-win32-arm64-msvc': 4.67.5 + '@rollup/rollup-win32-ia32-msvc': 4.67.5 + '@rollup/rollup-win32-x64-gnu': 4.67.5 + '@rollup/rollup-win32-x64-msvc': 4.67.5 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + 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 + + siginfo@2.0.0: {} + + sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + source-map-js@1.2.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + symbol-tree@3.2.4: {} + + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tslib@2.9.1: {} + + tw-animate-css@1.4.0: {} + + typescript@5.9.3: {} + + uqr@0.1.3: {} + + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + + vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.7 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.7: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.32 + rollup: 4.67.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.7(jsdom@26.1.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.7) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.7 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.22.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php new file mode 100644 index 0000000..96df747 --- /dev/null +++ b/resources/boost/guidelines/core.blade.php @@ -0,0 +1,10 @@ +{{-- + javaabu/settings guidelines for AI code assistants + + Maintained by: Javaabu (https://javaabu.com) +--}} + +## javaabu/settings + +- This project keeps app settings with `javaabu/settings`. Define settings with `Javaabu\Settings\Definitions\Setting::make()` on a `SettingsPage` (default, field and validation together), read them with `settings('key')` or the typed getters (`settings()->integer('per_page')`), and write them with `Settings::set([...])`. Don't add settings to `config/defaults.php`, query the `settings` table, or use anlutro's `Setting` facade. +- Always activate the `javaabu-settings` skill when adding, reading or changing a setting, building or changing a settings page, changing who may edit settings, or testing code that reads settings. diff --git a/resources/boost/skills/javaabu-settings/SKILL.md b/resources/boost/skills/javaabu-settings/SKILL.md new file mode 100644 index 0000000..8766bfb --- /dev/null +++ b/resources/boost/skills/javaabu-settings/SKILL.md @@ -0,0 +1,132 @@ +--- +name: javaabu-settings +description: Define, read, write and edit Laravel app settings with javaabu/settings. Use whenever the project depends on javaabu/settings and the task adds or changes a setting (site name, per page, logo, API keys, mail sender), reads one in code or Blade (settings(), get_setting(), Settings::get()), saves settings, builds or changes a settings page or its sections (SettingsPage, Section, Route::settings()), uploads a file setting, stores a secret, makes a setting translatable, changes who may edit settings, deals with queue workers seeing old settings, upgrades from settings v1, or tests code that reads settings. +license: MIT +metadata: + author: Javaabu +--- + +# Settings with javaabu/settings + +A setting is defined once, with its default, its [javaabu/stencil](https://github.com/Javaabu/stencil) field and its validation. The package reads, casts, caches and saves it, and builds the settings page. The docs ship with the package: `vendor/javaabu/settings/llms.txt` lists every page in `vendor/javaabu/settings/docs/`. + +| Task | Read | +| --- | --- | +| Define, read, write and show settings | This file | +| Every option, the page props, overriding, permissions | [references/api.md](references/api.md) | +| Mistakes to avoid, upgrading from v1 | [references/common-mistakes.md](references/common-mistakes.md) | + +## A settings page + +```php +default(config('app.name')) + ->label(__('Site name')) + ->required() + ->maxLength(255) + ->thaana(), + Setting::make('per_page') + ->default(20) + ->label(__('Items per page')) + ->integer() + ->minValue(1) + ->maxValue(100), + Setting::make('registration_open') + ->default(true) + ->field(Checkbox::class) + ->label(__('Registration is open')), + Setting::make('theme') + ->default(Themes::LIGHT) + ->cast(Themes::class) + ->field(Select::class) + ->options(Themes::class) + ->required(), + Setting::make('logo') + ->default('img/logo.png') + ->field(ImageUpload::class) + ->label(__('Logo')), + Section::make(__('Search engines'))->columns(2)->schema([ + Setting::make('seo_description')->field(Textarea::class)->label(__('Description'))->thaana(), + ]), + ]; + } +} +``` + +```php +// config/settings.php +'pages' => [App\Settings\GeneralSettings::class], + +// routes/web.php, inside the app's web (or admin) group +Route::settings(); // /settings +Route::settings(['guard' => 'web_admin', 'prefix' => 'admin/settings', 'name' => 'admin.settings']); +``` + +```bash +php artisan permissions:sync # view_settings, edit_settings +``` + +That's the whole page: tabs per page, the form, validation from the fields, saving, uploads, a success message, in Blade or Inertia React (`settings.views`). + +## Reading and writing + +```php +settings('site_name'); // the value, or its default +settings()->integer('per_page'); // typed: throws on the wrong type +settings()->boolean('registration_open'); +settings('theme'); // Themes::LIGHT (cast) +settings()->url('logo'); // the file's URL + +Settings::set(['site_name' => 'ދިވެހިރާއްޖެ', 'per_page' => 25]); // saved and broadcast to workers +Settings::forget('site_name'); // back to the default +``` + +```blade +{{ settings('site_name') }} + +``` + +## Rules + +- One `Setting::make()` per key, with its `default()`, field and rules. Calls the setting doesn't know go to the field (`label()`, `required()`, `integer()`, `options()`, `email()`, `thaana()`, `rules([...])`). Default field: `TextInput`. +- Pick the field for the type: `Checkbox` → bool, `->integer()` → int, `->numeric()` → float, `CheckboxList`/`->multiple()` → array, `->cast(Enum::class)` → enum. +- Secrets get `->encrypted()` (and usually `->password()`), never `->required()`. +- Pages: a `SettingsPage` class per page; `protected ?string $permission = 'edit_mail_settings'` for a page some users may edit alone; `Settings::page('mail')->label(fn () => __('Mail'))->schema(fn () => [...])` for an inline page (closures, so strings translate per request). +- Settings no one edits: `Settings::define([Setting::make('skeleton_version')->default('9.0.0')])`. +- Every label, help text, heading and option goes through `__()`; add `->thaana()` to Dhivehi text settings; `->translatable()` for a value per language (javaabu/translatable). +- Workers and Octane reload changed settings themselves, as long as the cache store is shared (Redis, database, file). +- Tests: `Settings::fake([...])`, or `FakesSettings`' `setFakeSetting('key', $value)`. + +## Common mistakes + +- Validating settings in a controller, or writing your own settings controller and form: define the field and rules on the `Setting` and use `Route::settings()`. +- `config('defaults.per_page')` or a new key in `config/defaults.php`: define the setting with `->default()`. +- `(int) settings('per_page')`: define it with `->integer()` and use `settings()->integer('per_page')`. +- `DB::table('settings')` or `Cache::forget(...)`: use `Settings::set()` / `forget()`; direct writes don't reach workers. +- `use anlutro\LaravelSettings\Facades\Setting; Setting::set(...); Setting::save();`: v1. Use `Settings::set()`. +- `Setting::make('site_name')->field(TextInput::make('name'))`: the field must be named after the setting; pass the class instead. +- Forgetting `Route::settings()` or `permissions:sync`: the pages 404 or 403. +- `php artisan queue:restart` after changing settings: not needed. diff --git a/resources/boost/skills/javaabu-settings/references/api.md b/resources/boost/skills/javaabu-settings/references/api.md new file mode 100644 index 0000000..1ce292e --- /dev/null +++ b/resources/boost/skills/javaabu-settings/references/api.md @@ -0,0 +1,85 @@ +# javaabu/settings API + +## Setting + +| Method | What | +| --- | --- | +| `Setting::make('key')` | A setting | +| `default($value)` | Default; a closure is called each time (`fn () => config('app.name')`) | +| `field(Field::class \| Field::make('key') \| fn (string $key) => Field)` | The stencil field; default `TextInput` | +| `cast('string' \| 'integer' \| 'float' \| 'boolean' \| 'array' \| Enum::class \| Closure)` | Override the cast from the field | +| `encrypted()` | Encrypted in the store, never sent to the browser, empty input keeps the value, hidden in the activity log | +| `translatable(layout: 'tabs', require_all: false)` | A value per language with javaabu/translatable (`{key}_{locale}` inputs) | +| any stencil field method | `label()`, `help()`, `placeholder()`, `required()`, `maxLength()`, `minValue()`, `options()`, `multiple()`, `email()`, `password()`, `thaana()`, `visible()`, `visibleWhen()`, `live()`, `rules()`, … | + +## Section + +`Section::make(__('Heading'))->description(__('...'))->columns(2)->schema([Setting..., Section...])`. Other methods go to stencil's Section. + +## SettingsPage + +```php +class MailSettings extends SettingsPage +{ + protected ?string $key = 'mail'; // URL segment; default from the class name + protected ?string $permission = 'edit_mail_settings'; // default edit_settings + protected int $order = 10; + + public function getLabel(): string { return __('Mail'); } + public function getDescription(): ?string { return __('Where emails come from'); } + public function getIcon(): ?string { return 'envelope'; } + public function isVisible(): bool { return true; } + + public function definition(): array { return [/* Setting and Section */]; } +} +``` + +Register: `config('settings.pages')`, or `Settings::page(MailSettings::class)` in `boot()`. Inline: `Settings::page('mail')->label(fn () => __('Mail'))->description(...)->icon(...)->permission(...)->order(...)->schema(fn () => [...])`. + +## Repository (`settings()`, `Settings::`) + +Read: `get($key, $default)`, `string|integer|float|boolean|array($key, $default)`, `default($key)`, `has($key)`, `all()`, `only([...])`, `url($key)`, `translate($key, $locale, $fallback)`, `translations($key)`, `definition($key)`, `pages()`. +Write: `set($key | [...], $value, causer: $user)`, `forget($keys, causer: $user)`, `flush()`. +Processes: `refreshIfStale()`, `refresh()`, `touch()`, `version()`. +Stores: `store()`, `extend('driver', fn ($app, array $config) => new MyStore)`. +Tests: `fake([...])`, `fakeDefault($key, $value)`, `isFaking()`, `stopFaking()`. + +Value order: stored → definition default → `config('defaults.{key}')` → `$default`. + +## Routes and the controller + +`Route::settings(['prefix' => 'settings', 'name' => 'settings', 'guard' => null, 'middleware' => ['auth'], 'controller' => SettingsController::class])`: + +| Route | Name | +| --- | --- | +| `GET {prefix}` | `{name}.index` (redirects to the first page) | +| `GET {prefix}/{settings_page}` | `{name}.edit` | +| `PATCH {prefix}/{settings_page}` | `{name}.update` | + +JSON requests get JSON. Override `SettingsController` (config `settings.controllers.settings`); hooks: `updated($request, $page, $values)`, `form()`, `viewData()`, `navigation()`. + +Page props (Blade variables and Inertia props of `settings::Settings/Edit`): `form` (the `SettingsForm`; JSON schema for Inertia), `page` (`key`, `label`, `description`, `icon`, `url`, `can_update`), `pages` (same, plus `active`), `status`, `strings` (`title`, `read_only`, `pages`). + +`SettingsForm::forPage('general')` is a stencil form class: `->action(...)`, `->saveSettings($request, $user)`, `->currentValues()`, `->assertable()`. + +## Permissions and policy + +`view_settings` (see every page, read-only), `edit_settings` (edit pages without their own permission), and each page's `$permission`. `SettingsPolicy` on `SettingsPage`: `viewAny`, `view`, `update`. Override with `config('permissions.policies')`, `Gate::policy(SettingsPage::class, ...)` or `config('settings.policy')`. + +## Config (`config/settings.php`) + +`store` (database), `stores.database.{connection,table}`, `stores.json.path`, `cache.{enabled,store,prefix}`, `refresh.{queue,octane}`, `pages`, `disk` (public), `directory` (settings), `views` (blade|inertia), `layout`, `layout_section`, `controllers.settings`, `policy`, `register_permissions`, `log_activity`. + +## Testing + +```php +Settings::fake(['registration_open' => false]); + +use Javaabu\Settings\Testing\FakesSettings; +$this->setFakeSetting('vat_rate', 8); +$this->setFakeDefaultSetting('per_page', 5); + +$this->actingAs($admin)->patch(route('settings.update', 'general'), [...])->assertSessionHasNoErrors(); + +SettingsForm::forPage('general')->assertable()->assertFieldRequired('site_name'); +``` diff --git a/resources/boost/skills/javaabu-settings/references/common-mistakes.md b/resources/boost/skills/javaabu-settings/references/common-mistakes.md new file mode 100644 index 0000000..4263397 --- /dev/null +++ b/resources/boost/skills/javaabu-settings/references/common-mistakes.md @@ -0,0 +1,28 @@ +# Common mistakes and upgrading from v1 + +## Mistakes + +| Wrong | Right | +| --- | --- | +| A settings controller with `$request->validate([...])` and `Setting::set()` | Rules on the `Setting` (`->required()->integer()`), `Route::settings()` | +| `'per_page' => 20` in `config/defaults.php` | `Setting::make('per_page')->default(20)->integer()` | +| `(int) get_setting('per_page')` | `settings()->integer('per_page')` | +| `Setting::make('logo')` with `->field(FileUpload::class)` then `asset(settings('logo'))` | `settings()->url('logo')` (handles uploads on the disk and default paths) | +| Storing an API key as a plain setting | `->encrypted()->password()` | +| `->encrypted()->required()` | No `required()`: the input is empty on every visit | +| `Setting::make('mail.from')` | Plain keys: `mail_from_address` | +| The same key on two pages | One definition per key (it throws) | +| `Settings::page('mail')->label(__('Mail'))` in a provider | `->label(fn () => __('Mail'))`: translated per request | +| `DB::table('settings')->update(...)` | `Settings::set(...)`: bumps the version so workers reload | +| `php artisan queue:restart` after saving settings | Not needed; use a shared cache store | +| `Setting::shouldReceive('get')` in tests | `Settings::fake([...])` or `setFakeSetting()` | +| `Gate::define('edit-settings', ...)` | `edit_settings` permission, or override `SettingsPolicy` | +| Editing the package's Blade view in `vendor/` | `php artisan vendor:publish --tag=settings-views`, or `settings.layout` | + +## Upgrading from v1 + +- `composer require javaabu/settings:^2.0`, `php artisan migrate`, then `php artisan settings:import-v1` (`--from=json` for `storage/settings.json`; `--force` to overwrite). Define settings first so imported strings are cast. +- Replace `anlutro\LaravelSettings\Facades\Setting` with `Javaabu\Settings\Facades\Settings`; drop `Setting::save()` (writes save immediately). +- Delete anlutro's `config/settings.php` (v2 has its own with the same name). +- `get_setting()`, `default_setting()`, `set_file_setting()`, `reset_file_setting()`, `config/defaults.php`, `FakesSettings` and `SettingsUpdated` keep working. +- Install `spatie/laravel-activitylog` yourself if you used the settings log. diff --git a/resources/js/Pages/Settings/Edit.tsx b/resources/js/Pages/Settings/Edit.tsx new file mode 100644 index 0000000..dbfc152 --- /dev/null +++ b/resources/js/Pages/Settings/Edit.tsx @@ -0,0 +1,24 @@ +import { Alert } from "@javaabu/forms"; +import { StencilForm } from "@javaabu/stencil"; +import { SettingsNav } from "../../components/SettingsNav"; +import type { SettingsEditProps } from "../../types"; + +/** settings::Settings/Edit — one settings page and its form */ +export default function Edit({ form, page, pages, status, strings }: SettingsEditProps) { + return ( +
+
+

{strings.title}

+ {page.description ?

{page.description}

: null} +
+ + + + {status ? {status} : null} + + {page.can_update ? null : {strings.read_only}} + + +
+ ); +} diff --git a/resources/js/components/SettingsNav.tsx b/resources/js/components/SettingsNav.tsx new file mode 100644 index 0000000..24b3119 --- /dev/null +++ b/resources/js/components/SettingsNav.tsx @@ -0,0 +1,29 @@ +import { NavTabs } from "@javaabu/forms"; +import type { SettingsPageData } from "../types"; + +export interface SettingsNavProps { + pages: SettingsPageData[]; + /** The accessible name of the navigation */ + label?: string; + className?: string; +} + +/** Tabs that link to each settings page; nothing when there's only one page */ +export function SettingsNav({ pages, label, className }: SettingsNavProps) { + if (pages.length < 2) { + return null; + } + + return ( +
+ ({ + title: page.label, + url: page.url, + active: page.active, + icon: page.icon ?? undefined, + }))} + /> +
+ ); +} diff --git a/resources/js/index.ts b/resources/js/index.ts new file mode 100644 index 0000000..452d8b6 --- /dev/null +++ b/resources/js/index.ts @@ -0,0 +1,4 @@ +// What apps import as @javaabu/settings (through javaabu/manifest) +export { default as SettingsEdit } from "./Pages/Settings/Edit"; +export { SettingsNav, type SettingsNavProps } from "./components/SettingsNav"; +export type * from "./types"; diff --git a/resources/js/types.ts b/resources/js/types.ts new file mode 100644 index 0000000..6f19e6d --- /dev/null +++ b/resources/js/types.ts @@ -0,0 +1,31 @@ +import type { StencilSchema } from "@javaabu/stencil"; + +/** A settings page, as SettingsController sends it */ +export interface SettingsPageData { + key: string; + label: string; + description: string | null; + icon: string | null; + url: string; + can_update: boolean; + /** Only in `pages`: whether it's the page being shown */ + active?: boolean; +} + +export interface SettingsStrings { + title: string; + read_only: string; + pages: string; +} + +/** The props of settings::Settings/Edit */ +export interface SettingsEditProps { + /** The page's stencil form */ + form: StencilSchema; + page: SettingsPageData; + /** Every page the user can see, for navigation */ + pages: SettingsPageData[]; + /** The message after saving */ + status: string | null; + strings: SettingsStrings; +} diff --git a/resources/views/settings/edit.blade.php b/resources/views/settings/edit.blade.php new file mode 100644 index 0000000..be09442 --- /dev/null +++ b/resources/views/settings/edit.blade.php @@ -0,0 +1,31 @@ +@extends($layout) + +@section($layout_section) +
+
+

{{ $strings['title'] }}

+ @if($page['description']) +

{{ $page['description'] }}

+ @endif +
+ + @if(count($pages) > 1) + + @endif + + @if($status) + {{ $status }} + @endif + + @unless($page['can_update']) + {{ $strings['read_only'] }} + @endunless + + {{ $form }} +
+@endsection diff --git a/src/Commands/ImportV1SettingsCommand.php b/src/Commands/ImportV1SettingsCommand.php new file mode 100644 index 0000000..d9c638e --- /dev/null +++ b/src/Commands/ImportV1SettingsCommand.php @@ -0,0 +1,100 @@ +option('from')) { + 'database' => $this->fromDatabase(), + 'json' => $this->fromJson($files), + default => null, + }; + + if ($values === null) { + $this->error('Nothing to import: check --from, --table and --path.'); + + return self::FAILURE; + } + + $import = []; + + foreach ($values as $key => $value) { + if ($settings->has($key) && ! $this->option('force')) { + $this->line("Skipping `{$key}`: it already has a value (use --force to overwrite)."); + + continue; + } + + $this->line("Importing `{$key}`..."); + + $definition = $settings->definition($key); + + $import[$key] = $definition ? $definition->castValue($value) : $value; + } + + $settings->set($import); + + $count = count($import); + + $this->info("Imported {$count} settings."); + + return self::SUCCESS; + } + + /** + * @return array|null + */ + protected function fromDatabase(): ?array + { + $connection = $this->laravel['db']->connection($this->option('connection')); + $table = (string) $this->option('table'); + + if (! $connection->getSchemaBuilder()->hasTable($table)) { + return null; + } + + $values = []; + + foreach ($connection->table($table)->get(['key', 'value']) as $row) { + Arr::set($values, $row->key, $row->value); + } + + return $values; + } + + /** + * @return array|null + */ + protected function fromJson(Filesystem $files): ?array + { + $path = $this->option('path') ?: storage_path('settings.json'); + + if (! $files->exists($path)) { + return null; + } + + $values = json_decode($files->get($path), true); + + return is_array($values) ? $values : null; + } +} diff --git a/src/Contracts/SettingsStore.php b/src/Contracts/SettingsStore.php new file mode 100644 index 0000000..cc0d8eb --- /dev/null +++ b/src/Contracts/SettingsStore.php @@ -0,0 +1,36 @@ + + */ + public function all(): array; + + /** + * Store the values, replacing the ones with the same keys + * + * @param array $values + */ + public function put(array $values): void; + + /** + * Remove the values of the keys + * + * @param array $keys + */ + public function forget(array $keys): void; + + /** + * Remove every value + */ + public function flush(): void; +} diff --git a/src/Definitions/InlinePage.php b/src/Definitions/InlinePage.php new file mode 100644 index 0000000..ac14be7 --- /dev/null +++ b/src/Definitions/InlinePage.php @@ -0,0 +1,104 @@ +label(fn () => __('Mail')) + * ->schema(fn () => [ + * Setting::make('mail_from_address')->email()->required(), + * ]); + * ``` + * + * Pass closures for the label and schema so their strings are translated in + * the locale of each request. + */ +class InlinePage extends SettingsPage +{ + /** @var array|Closure */ + protected array|Closure $schema = []; + + protected string|Closure|null $label = null; + + protected string|Closure|null $description = null; + + protected ?string $icon = null; + + final public function __construct(string $key) + { + $this->key = $key; + } + + /** + * @param array|Closure $schema + */ + public function schema(array|Closure $schema): static + { + $this->schema = $schema; + $this->settings = null; + + return $this; + } + + public function label(string|Closure|null $label): static + { + $this->label = $label; + + return $this; + } + + public function description(string|Closure|null $description): static + { + $this->description = $description; + + return $this; + } + + public function icon(?string $icon): static + { + $this->icon = $icon; + + return $this; + } + + public function permission(?string $permission): static + { + $this->permission = $permission; + + return $this; + } + + public function order(int $order): static + { + $this->order = $order; + + return $this; + } + + public function definition(): array + { + return $this->schema instanceof Closure ? app()->call($this->schema) : $this->schema; + } + + public function getLabel(): string + { + $label = $this->label instanceof Closure ? ($this->label)() : $this->label; + + return $label ?? parent::getLabel(); + } + + public function getDescription(): ?string + { + return $this->description instanceof Closure ? ($this->description)() : $this->description; + } + + public function getIcon(): ?string + { + return $this->icon; + } +} diff --git a/src/Definitions/Section.php b/src/Definitions/Section.php new file mode 100644 index 0000000..1bc8916 --- /dev/null +++ b/src/Definitions/Section.php @@ -0,0 +1,93 @@ +description(__('How the site appears in search results')) + * ->columns(2) + * ->schema([ + * Setting::make('seo_description')->field(Textarea::class), + * Setting::make('seo_keywords'), + * ]); + * ``` + * + * Other methods (columns, collapsible, columnSpanFull, …) are passed to the + * stencil section. + * + * @mixin StencilSection + */ +class Section +{ + /** @var array|Closure */ + protected array|Closure $schema = []; + + /** @var array}> */ + protected array $section_calls = []; + + final public function __construct( + protected string|Htmlable|Closure|null $heading = null, + ) {} + + /** + * @param array|Closure $schema + */ + public static function make(string|Htmlable|Closure|null $heading = null, array|Closure $schema = []): static + { + return (new static($heading))->schema($schema); + } + + /** + * @param array|Closure $schema + */ + public function schema(array|Closure $schema): static + { + $this->schema = $schema; + + return $this; + } + + /** + * @return array + */ + public function getSchema(): array + { + $schema = $this->schema instanceof Closure ? app()->call($this->schema) : $this->schema; + + return array_values(array_filter($schema)); + } + + /** + * A stencil section with the given children + * + * @param array $children + */ + public function toStencil(array $children): StencilSection + { + $section = StencilSection::make($this->heading, $children); + + foreach ($this->section_calls as [$method, $arguments]) { + $section->{$method}(...$arguments); + } + + return $section; + } + + /** + * @param array $arguments + */ + public function __call(string $method, array $arguments): static + { + $this->section_calls[] = [$method, $arguments]; + + return $this; + } +} diff --git a/src/Definitions/Setting.php b/src/Definitions/Setting.php new file mode 100644 index 0000000..ef9e10c --- /dev/null +++ b/src/Definitions/Setting.php @@ -0,0 +1,323 @@ +default(config('app.name')) + * ->field(TextInput::class) + * ->label(__('Site name')) + * ->required() + * ->maxLength(255); + * ``` + * + * Methods the setting doesn't have (label, required, maxLength, options, …) are + * passed to the field. + * + * @mixin TextInput + */ +class Setting +{ + protected mixed $default = null; + + /** @var Field|class-string|Closure */ + protected Field|string|Closure $field = TextInput::class; + + /** @var array}> */ + protected array $field_calls = []; + + protected string|Closure|null $cast = null; + + protected bool $is_translatable = false; + + /** @var array{layout: string, require_all: bool} */ + protected array $translatable_options = ['layout' => 'tabs', 'require_all' => false]; + + protected bool $is_encrypted = false; + + protected string|false|null $field_cast = null; + + final public function __construct( + protected string $key, + ) {} + + public static function make(string $key): static + { + return new static($key); + } + + public function getKey(): string + { + return $this->key; + } + + /** + * The value when nothing is stored. A closure is called each time the + * default is needed. + */ + public function default(mixed $value): static + { + $this->default = $value; + + return $this; + } + + public function getDefault(): mixed + { + $default = $this->default instanceof Closure + ? app()->call($this->default) + : $this->default; + + return $this->castValue($default); + } + + /** + * The stencil field that edits the setting: a field class (named after the + * setting), a field named after the setting, or a closure given the key. + * + * @param Field|class-string|Closure(string): Field $field + */ + public function field(Field|string|Closure $field): static + { + if (is_string($field) && ! is_a($field, Field::class, true)) { + throw SettingsException::invalidField($this->key); + } + + $this->field = $field; + $this->field_cast = null; + + return $this; + } + + /** + * A fresh field for a form, with the setting's options applied + */ + public function getField(): Field + { + $field = $this->makeField(); + + if ($field instanceof FileUpload) { + $field->disk(config('settings.disk'))->directory(config('settings.directory', 'settings')); + } + + if ($this->is_encrypted) { + $field->help(__('Leave blank to keep the current value.')); + } + + foreach ($this->field_calls as [$method, $arguments]) { + $field->{$method}(...$arguments); + } + + if ($this->is_translatable) { + $field->translatable(true, ...$this->translatable_options); + } + + return $field; + } + + /** + * How stored values and defaults are read: string, integer, float, boolean, + * array, an enum class, or a closure given the value. When it isn't set, + * it comes from the field: checkboxes are booleans, integer() inputs + * integers, numeric() inputs floats, lists and multiple selects arrays. + * + * @param string|class-string|(Closure(mixed): mixed)|null $cast + */ + public function cast(string|Closure|null $cast): static + { + $this->cast = $cast; + + return $this; + } + + public function getCast(): string|Closure|null + { + if ($this->cast !== null) { + return $this->cast; + } + + if ($this->field_cast === null) { + $this->field_cast = $this->castFromField() ?? false; + } + + return $this->field_cast ?: null; + } + + /** + * One value per language, when javaabu/translatable is installed and + * enabled. Otherwise a plain setting. + * + * @param string $layout tabs, or stacked for one input after another + * @param bool $require_all required in every language, not only the default one + */ + public function translatable(bool $condition = true, string $layout = 'tabs', bool $require_all = false): static + { + $this->is_translatable = $condition; + $this->translatable_options = ['layout' => $layout, 'require_all' => $require_all]; + + return $this; + } + + /** + * Whether the setting is translatable now: declared translatable, and + * javaabu/translatable is installed and enabled + */ + public function isTranslatable(): bool + { + return $this->is_translatable && Translations::enabled(); + } + + public function isDeclaredTranslatable(): bool + { + return $this->is_translatable; + } + + /** + * Keep the value encrypted in the store (API keys, passwords) + */ + public function encrypted(bool $condition = true): static + { + $this->is_encrypted = $condition; + + return $this; + } + + public function isEncrypted(): bool + { + return $this->is_encrypted; + } + + public function isFile(): bool + { + return $this->makeField() instanceof FileUpload; + } + + /** + * Read a stored value or a default with the setting's cast + */ + public function castValue(mixed $value): mixed + { + if ($value === null) { + return null; + } + + $cast = $this->getCast(); + + if ($cast instanceof Closure) { + return $cast($value); + } + + return match ($cast) { + null => $value, + 'string' => is_scalar($value) ? (string) $value : $value, + 'int', 'integer' => is_numeric($value) ? (int) $value : $value, + 'float', 'double' => is_numeric($value) ? (float) $value : $value, + 'bool', 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value, + 'array' => is_array($value) ? $value : (array) $value, + default => $this->castToEnum($cast, $value), + }; + } + + /** + * Prepare a value for the store: enums as their values + */ + public function storableValue(mixed $value): mixed + { + if ($value instanceof BackedEnum) { + return $value->value; + } + + return $value; + } + + /** + * @param array $arguments + */ + public function __call(string $method, array $arguments): static + { + $this->field_calls[] = [$method, $arguments]; + $this->field_cast = null; + + return $this; + } + + protected function makeField(): Field + { + $field = $this->field; + + if ($field instanceof Closure) { + $field = $field($this->key); + } + + if (is_string($field)) { + return $field::make($this->key); + } + + if (! $field instanceof Field) { + throw SettingsException::invalidField($this->key); + } + + if ($field->getName() !== Field::normalizeName($this->key)) { + throw SettingsException::fieldNameMismatch($this->key, $field->getName()); + } + + return clone $field; + } + + protected function castFromField(): ?string + { + $field = $this->getField(); + + if ($field instanceof Checkbox) { + return 'boolean'; + } + + if ($field instanceof CheckboxList) { + return 'array'; + } + + if ($field instanceof Select && $field->isMultiple()) { + return 'array'; + } + + if ($field instanceof FileUpload && $field->isMultiple()) { + return 'array'; + } + + if ($field instanceof TextInput && $field->isNumeric()) { + $type_rules = (fn () => $this->getTypeRules())->call($field); + + return in_array('integer', $type_rules, true) ? 'integer' : 'float'; + } + + return null; + } + + protected function castToEnum(string $cast, mixed $value): mixed + { + if ($value instanceof BackedEnum) { + return $value; + } + + if (is_a($cast, BackedEnum::class, true)) { + return $cast::tryFrom($value) ?? $value; + } + + return $value; + } +} diff --git a/src/Definitions/SettingsPage.php b/src/Definitions/SettingsPage.php new file mode 100644 index 0000000..35792a1 --- /dev/null +++ b/src/Definitions/SettingsPage.php @@ -0,0 +1,189 @@ +default(config('app.name'))->required(), + * Section::make(__('Search engines'))->schema([ + * Setting::make('seo_description')->field(Textarea::class)->thaana(), + * ]), + * ]; + * } + * } + * ``` + */ +abstract class SettingsPage +{ + /** The key in the URL and route, e.g. general for /settings/general. Defaults to the class name without "Settings", in kebab case */ + protected ?string $key = null; + + /** The permission that lets a user edit this page; null for config('settings') default (edit_settings) */ + protected ?string $permission = null; + + /** Pages are listed by order, then in the order they were registered */ + protected int $order = 0; + + /** @var array|null */ + protected ?array $settings = null; + + /** + * The settings and sections on the page + * + * @return array + */ + abstract public function definition(): array; + + public function getKey(): string + { + return $this->key ?? Str::of(class_basename($this))->beforeLast('Settings')->whenEmpty(fn () => Str::of(class_basename($this)))->kebab()->toString(); + } + + public function getLabel(): string + { + return __(Str::headline($this->getKey())); + } + + public function getDescription(): ?string + { + return null; + } + + /** + * An icon name for navigation, if your layout shows one + */ + public function getIcon(): ?string + { + return null; + } + + public function getPermission(): ?string + { + return $this->permission; + } + + public function getOrder(): int + { + return $this->order; + } + + /** + * Every setting on the page, keyed by setting key + * + * @return array + */ + public function settings(): array + { + if ($this->settings !== null) { + return $this->settings; + } + + $settings = []; + + $this->collectSettings($this->definition(), $settings); + + return $this->settings = $settings; + } + + public function setting(string $key): ?Setting + { + return $this->settings()[$key] ?? null; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->settings()); + } + + /** + * The page's stencil components, built fresh (so labels are in the current locale) + * + * @return array + */ + public function components(): array + { + return $this->toComponents($this->definition()); + } + + /** + * Forget the settings read from the definition, so they're read again + */ + public function flush(): void + { + $this->settings = null; + } + + /** + * Whether a user who can edit settings may see the page; override to hide it + */ + public function isVisible(): bool + { + return true; + } + + /** + * @param array $items + * @param array $settings + */ + protected function collectSettings(array $items, array &$settings): void + { + foreach (array_filter($items) as $item) { + if ($item instanceof Setting) { + $settings[$item->getKey()] = $item; + + continue; + } + + if ($item instanceof Section) { + $this->collectSettings($item->getSchema(), $settings); + + continue; + } + + throw SettingsException::invalidSchemaItem($this->getKey(), $item); + } + } + + /** + * @param array $items + * @return array + */ + protected function toComponents(array $items): array + { + $components = []; + + foreach (array_filter($items) as $item) { + if ($item instanceof Setting) { + $components[] = $item->getField(); + + continue; + } + + if ($item instanceof Section) { + $components[] = $item->toStencil($this->toComponents($item->getSchema())); + + continue; + } + + throw SettingsException::invalidSchemaItem($this->getKey(), $item); + } + + return $components; + } +} diff --git a/src/Events/SettingsUpdated.php b/src/Events/SettingsUpdated.php index 48feac4..e573ccc 100644 --- a/src/Events/SettingsUpdated.php +++ b/src/Events/SettingsUpdated.php @@ -2,41 +2,54 @@ namespace Javaabu\Settings\Events; -use App\Models\User; -use Illuminate\Queue\SerializesModels; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; +/** + * Settings changed. Holds the values of the changed keys before and after. + */ class SettingsUpdated { use Dispatchable; use SerializesModels; /** - * @var User + * @param array $old_settings + * @param array $new_settings */ - public $causer; + public function __construct( + public array $old_settings, + public array $new_settings, + public ?Model $causer = null, + ) {} /** - * @var array + * The keys whose value changed, with their new values + * + * @return array */ - public $old_settings; + public function changes(): array + { + $changes = []; - /** - * @var array - */ - public $new_settings; + foreach ($this->new_settings as $key => $value) { + if (($this->old_settings[$key] ?? null) != $value) { + $changes[$key] = $value; + } + } - /** - * Create a new event instance. - * - * @param array $old_settings - * @param array $new_settings - * @param User|null $causer - */ - public function __construct($old_settings, $new_settings, $causer = null) + foreach ($this->old_settings as $key => $value) { + if (! array_key_exists($key, $this->new_settings) && $value !== null) { + $changes[$key] = null; + } + } + + return $changes; + } + + public function changed(string $key): bool { - $this->old_settings = $old_settings; - $this->new_settings = $new_settings; - $this->causer = $causer; + return array_key_exists($key, $this->changes()); } } diff --git a/src/Exceptions/SettingsException.php b/src/Exceptions/SettingsException.php new file mode 100644 index 0000000..89e5bb1 --- /dev/null +++ b/src/Exceptions/SettingsException.php @@ -0,0 +1,52 @@ +field(TextInput::class) or name it [{$key}]."); + } + + public static function invalidField(string $key): static + { + return new static("The field of setting [{$key}] must be a stencil Field, a Field class name, or a closure that returns a Field."); + } + + public static function pageNotFound(string $key): static + { + return new static("There is no settings page [{$key}]."); + } + + public static function invalidPage(string $page): static + { + return new static("[{$page}] is not a settings page. Extend Javaabu\\Settings\\Definitions\\SettingsPage."); + } + + public static function invalidSchemaItem(string $page, mixed $item): static + { + $type = get_debug_type($item); + + return new static("Settings page [{$page}] has a [{$type}] in its schema. Use Setting::make() and Section::make()."); + } + + public static function duplicateKey(string $key, string $page, string $other): static + { + return new static("Setting [{$key}] is defined on both the [{$other}] and [{$page}] pages."); + } +} diff --git a/src/Facades/Settings.php b/src/Facades/Settings.php new file mode 100644 index 0000000..03a1490 --- /dev/null +++ b/src/Facades/Settings.php @@ -0,0 +1,66 @@ + translations(string $key) + * @method static mixed default(string $key) + * @method static bool has(string $key) + * @method static array all() + * @method static array only(array $keys) + * @method static array stored() + * @method static string string(string $key, ?string $default = null) + * @method static int integer(string $key, ?int $default = null) + * @method static float float(string $key, ?float $default = null) + * @method static bool boolean(string $key, ?bool $default = null) + * @method static array array(string $key, ?array $default = null) + * @method static string|null url(string $key) + * @method static SettingsRepository set(string|array $key, mixed $value = null, ?Model $causer = null) + * @method static SettingsRepository forget(string|array $keys, ?Model $causer = null) + * @method static SettingsRepository flush() + * @method static bool refreshIfStale() + * @method static SettingsRepository refresh() + * @method static string|null version() + * @method static SettingsRepository touch() + * @method static SettingsPage page(SettingsPage|string $page) + * @method static SettingsRepository define(array|Setting $settings) + * @method static array pages() + * @method static Setting|null definition(string $key) + * @method static SettingsRegistry registry() + * @method static SettingsStore store() + * @method static SettingsRepository extend(string $driver, Closure $creator) + * @method static SettingsRepository fake(array $values = []) + * @method static bool isFaking() + * @method static SettingsRepository fakeDefault(string $key, mixed $value) + * @method static SettingsRepository stopFaking() + * + * @see SettingsRepository + */ +class Settings extends Facade +{ + public static bool $runs_migrations = true; + + /** + * Stop the package from loading its migration, e.g. after publishing it + */ + public static function ignoreMigrations(): void + { + static::$runs_migrations = false; + } + + protected static function getFacadeAccessor(): string + { + return SettingsRepository::class; + } +} diff --git a/src/Forms/SettingsForm.php b/src/Forms/SettingsForm.php new file mode 100644 index 0000000..5815376 --- /dev/null +++ b/src/Forms/SettingsForm.php @@ -0,0 +1,227 @@ +action(route('settings.update', 'general')); + * + * $values = SettingsForm::forPage('general')->saveSettings($request, $request->user()); + * ``` + */ +class SettingsForm extends Form +{ + /** + * The form of a page, filled with the current values + */ + public static function forPage(SettingsPage|string $page, ?string $guard = null): static + { + $key = $page instanceof SettingsPage ? $page->getKey() : $page; + + $form = static::make("settings-{$key}")->with([ + 'page' => $key, + 'guard' => $guard, + ]); + + return $form->model($form->initialRecord()); + } + + protected function setUp(): void + { + $this->submit(__('Save settings')); + } + + protected function definition(): array + { + return $this->page()->components(); + } + + public function page(): SettingsPage + { + return app(SettingsRegistry::class)->findOrFail((string) $this->context('page')); + } + + public function guard(): ?string + { + return $this->context('guard'); + } + + /** + * Live fields re-evaluate only for users who may edit the page + */ + public function authorize(): bool + { + $user = Auth::guard($this->guard())->user(); + + return $user !== null && Gate::forUser($user)->allows('update', $this->page()); + } + + /** + * What the form edits: the current values, or (when javaabu/translatable is + * enabled and the page has translatable settings) a record that holds every + * locale's value + * + * @return array|SettingsRecord + */ + public function initialRecord(): array|SettingsRecord + { + $values = $this->currentValues(); + + $translatable = array_filter($this->page()->settings(), fn (Setting $setting) => $setting->isTranslatable()); + + if ($translatable === []) { + return $values; + } + + $settings = app(SettingsRepository::class); + + foreach (array_keys($translatable) as $key) { + $values[$key] = $settings->translations($key); + } + + return SettingsRecord::fromValues($values); + } + + /** + * The values the form starts with. Encrypted settings are left empty, so + * they never reach the browser; file settings are their URLs, for previews. + * Translatable settings also have a value per locale ({key}_{locale}). + * + * @return array + */ + public function currentValues(): array + { + $settings = app(SettingsRepository::class); + + $values = []; + + foreach ($this->page()->settings() as $key => $setting) { + $values[$key] = match (true) { + $setting->isEncrypted() => null, + $setting->isFile() && ! is_array($settings->get($key)) => $settings->url($key), + default => $settings->get($key), + }; + + if (! $setting->isTranslatable()) { + continue; + } + + $translations = $settings->translations($key); + + foreach (Translations::locales() as $locale) { + $values["{$key}_{$locale}"] = $translations[$locale] ?? null; + } + } + + return $values; + } + + /** + * Validate the request and store the page's settings. Files are uploaded, + * and a replaced upload is deleted. An encrypted setting left empty keeps + * its value. + * + * @return array the values stored + */ + public function saveSettings(Request|array|null $input = null, ?Model $causer = null): array + { + $data = $this->validate($input); + + $settings = app(SettingsRepository::class); + + $values = []; + $replaced_files = []; + + foreach ($this->page()->settings() as $key => $setting) { + if ($setting->isTranslatable()) { + $translations = $this->translationsFrom($data, $key); + + if ($translations !== []) { + $values[$key] = array_merge($settings->translations($key), $translations); + } + + continue; + } + + if (! Arr::has($data, $key)) { + continue; + } + + $value = Arr::get($data, $key); + + if ($setting->isEncrypted() && ($value === null || $value === '')) { + continue; + } + + if ($setting->isFile() && $settings->has($key)) { + $replaced_files[] = $settings->get($key); + } + + $values[$key] = $value; + } + + $settings->set($values, causer: $causer); + + $this->deleteFiles($replaced_files, $values); + + return $values; + } + + /** + * The submitted value of each locale of a translatable setting + * + * @param array $data + * @return array + */ + protected function translationsFrom(array $data, string $key): array + { + $translations = []; + + foreach (Translations::locales() as $locale) { + $name = "{$key}_{$locale}"; + + if (array_key_exists($name, $data)) { + $translations[$locale] = $data[$name]; + } + } + + return $translations; + } + + /** + * @param array $files + * @param array $values + */ + protected function deleteFiles(array $files, array $values): void + { + $disk = Storage::disk(config('settings.disk')); + $kept = Arr::flatten($values); + + foreach (Arr::flatten($files) as $file) { + if (! is_string($file) || in_array($file, $kept, true)) { + continue; + } + + if ($disk->exists($file)) { + $disk->delete($file); + } + } + } +} diff --git a/src/Http/Controllers/SettingsController.php b/src/Http/Controllers/SettingsController.php new file mode 100644 index 0000000..edbadc1 --- /dev/null +++ b/src/Http/Controllers/SettingsController.php @@ -0,0 +1,210 @@ +authorizeFor($request, 'viewAny', SettingsPage::class); + + $pages = $this->navigation($request); + + if ($request->expectsJson()) { + return new JsonResponse(['data' => $pages]); + } + + if ($pages === []) { + abort(403); + } + + return redirect()->to($pages[0]['url']); + } + + public function edit(Request $request, string $settings_page): View|Responsable|JsonResponse + { + $page = $this->findPage($settings_page); + + $this->authorizeFor($request, 'view', $page); + + $form = $this->form($request, $page); + + if ($request->expectsJson()) { + return new JsonResponse([ + 'page' => $this->pageData($request, $page), + 'values' => $form->currentValues(), + ]); + } + + if (! $this->user($request) || ! $this->gate($request)->allows('update', $page)) { + $form->disabled()->submit(false); + } + + return $this->views->render('settings.edit', $this->viewData($request, $page, $form)); + } + + public function update(Request $request, string $settings_page): RedirectResponse|JsonResponse + { + $page = $this->findPage($settings_page); + + $this->authorizeFor($request, 'update', $page); + + $values = $this->form($request, $page)->saveSettings($request, $this->user($request)); + + $this->updated($request, $page, $values); + + $message = __('Settings saved.'); + + if ($request->expectsJson()) { + return new JsonResponse([ + 'message' => $message, + 'values' => $this->form($request, $page)->currentValues(), + ]); + } + + return redirect() + ->route($this->routeName($request, 'edit'), $page->getKey()) + ->with('status', $message); + } + + /** + * Called after a page is saved, with the values stored + * + * @param array $values + */ + protected function updated(Request $request, SettingsPage $page, array $values): void {} + + protected function form(Request $request, SettingsPage $page): SettingsForm + { + return SettingsForm::forPage($page, SettingsRoutes::guard($request)) + ->action(route($this->routeName($request, 'update'), $page->getKey())) + ->method('PATCH'); + } + + /** + * What the Blade view and the Inertia page get + * + * @return array + */ + protected function viewData(Request $request, SettingsPage $page, SettingsForm $form): array + { + $inertia = $this->views->isInertia(); + + return [ + 'form' => $inertia ? $form->render('json') : $form, + 'page' => $this->pageData($request, $page), + 'pages' => $this->navigation($request, $page), + 'status' => $request->session()->get('status'), + 'strings' => [ + 'title' => __('Settings'), + 'read_only' => __('You can view these settings but not change them.'), + 'pages' => __('Settings pages'), + ], + 'layout' => config('settings.layout', 'layouts.app'), + 'layout_section' => config('settings.layout_section', 'content'), + ]; + } + + /** + * The pages the user can see, for navigation + * + * @return array> + */ + protected function navigation(Request $request, ?SettingsPage $current = null): array + { + $pages = []; + + foreach ($this->registry->all() as $page) { + if (! $this->gate($request)->allows('view', $page)) { + continue; + } + + $pages[] = [ + ...$this->pageData($request, $page), + 'active' => $current?->getKey() === $page->getKey(), + ]; + } + + return $pages; + } + + /** + * @return array + */ + protected function pageData(Request $request, SettingsPage $page): array + { + return [ + 'key' => $page->getKey(), + 'label' => $page->getLabel(), + 'description' => $page->getDescription(), + 'icon' => $page->getIcon(), + 'url' => route($this->routeName($request, 'edit'), $page->getKey()), + 'can_update' => $this->gate($request)->allows('update', $page), + ]; + } + + protected function findPage(string $key): SettingsPage + { + $page = $this->registry->find($key); + + if (! $page || ! $page->isVisible()) { + throw new NotFoundHttpException; + } + + return $page; + } + + protected function authorizeFor(Request $request, string $ability, mixed $argument): void + { + $this->gate($request)->authorize($ability, $argument); + } + + protected function gate(Request $request): Gate + { + $gate = app(Gate::class); + + $user = $this->user($request); + + return $user ? $gate->forUser($user) : $gate; + } + + protected function user(Request $request): ?Model + { + $user = $request->user(SettingsRoutes::guard($request)); + + return $user instanceof Model ? $user : null; + } + + protected function routeName(Request $request, string $action): string + { + return SettingsRoutes::namePrefix($request) . ".{$action}"; + } +} diff --git a/src/Listeners/LogSettingsUpdate.php b/src/Listeners/LogSettingsUpdate.php index ef182d7..1c122b7 100644 --- a/src/Listeners/LogSettingsUpdate.php +++ b/src/Listeners/LogSettingsUpdate.php @@ -3,43 +3,40 @@ namespace Javaabu\Settings\Listeners; use Javaabu\Settings\Events\SettingsUpdated; +use Javaabu\Settings\Facades\Settings; +/** + * Logs changed settings to spatie/laravel-activitylog, with the old and new + * values. Registered only when the package is installed. Encrypted settings + * are logged as changed, without their values. + */ class LogSettingsUpdate { - /** - * Create the event listener. - * - * @return void - */ - public function __construct() - { - // - } + public const string HIDDEN = '********'; - /** - * Handle the event. - * - * @param SettingsUpdated $event - * @return void - */ - public function handle(SettingsUpdated $event) + public function handle(SettingsUpdated $event): void { - $attributes = []; + $attributes = $event->changes(); + + if ($attributes === []) { + return; + } + $old = []; - foreach ($event->old_settings as $key => $old_value) { - $new_value = $event->new_settings[$key] ?? null; + foreach (array_keys($attributes) as $key) { + $old[$key] = $event->old_settings[$key] ?? null; - if ($old_value != $new_value) { - $attributes[$key] = $new_value; - $old[$key] = $old_value; + if (Settings::definition($key)?->isEncrypted()) { + $attributes[$key] = self::HIDDEN; + $old[$key] = self::HIDDEN; } } $log = activity()->withProperties(compact('attributes', 'old')); - if ($user = $event->causer) { - $log->causedBy($user); + if ($event->causer) { + $log->causedBy($event->causer); } $log->log('settings'); diff --git a/src/Policies/SettingsPolicy.php b/src/Policies/SettingsPolicy.php new file mode 100644 index 0000000..4f91365 --- /dev/null +++ b/src/Policies/SettingsPolicy.php @@ -0,0 +1,74 @@ +getPermission()) { + $permissions[] = $page->getPermission(); + } + } + + return $this->allows($user, ...array_unique($permissions)); + } + + public function view(Model $user, SettingsPage $page): bool + { + if ($this->allows($user, self::VIEW)) { + return true; + } + + return $this->update($user, $page); + } + + public function update(Model $user, SettingsPage $page): bool + { + return $this->allows($user, $page->getPermission() ?? self::EDIT); + } + + /** + * Whether the user has any of the permissions + */ + protected function allows(Model $user, string ...$permissions): bool + { + foreach ($permissions as $permission) { + $allowed = $this->guard !== null && method_exists($user, 'checkPermissionTo') + ? $user->checkPermissionTo($permission, $this->guard) + : $user->can($permission); + + if ($allowed) { + return true; + } + } + + return false; + } +} diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php deleted file mode 100644 index 64c888f..0000000 --- a/src/Providers/EventServiceProvider.php +++ /dev/null @@ -1,25 +0,0 @@ - [ - LogSettingsUpdate::class, - ], - ]; - - public function boot(): void - { - parent::boot(); - } -} diff --git a/src/Routing/SettingsRoutes.php b/src/Routing/SettingsRoutes.php new file mode 100644 index 0000000..26b4326 --- /dev/null +++ b/src/Routing/SettingsRoutes.php @@ -0,0 +1,76 @@ + 'web_admin', 'prefix' => 'admin/settings', 'name' => 'admin.settings']); + * ``` + * + * | Route | Name | Action | + * | ----------------------------- | ---------------- | ------ | + * | GET {prefix} | {name}.index | index | + * | GET {prefix}/{settings_page} | {name}.edit | edit | + * | PATCH {prefix}/{settings_page}| {name}.update | update | + */ +class SettingsRoutes +{ + /** The route action key that holds the guard the routes were registered for */ + public const string GUARD_ACTION = 'settings_guard'; + + /** + * @param array{ + * guard?: string|null, + * prefix?: string, + * name?: string, + * middleware?: string|array|null, + * controller?: class-string|null + * } $options + */ + public static function register(Router $router, array $options = []): void + { + $guard = $options['guard'] ?? null; + $prefix = trim($options['prefix'] ?? 'settings', '/'); + $name = rtrim($options['name'] ?? 'settings', '.'); + $middleware = $options['middleware'] ?? [$guard ? "auth:{$guard}" : 'auth']; + $controller = $options['controller'] ?? config('settings.controllers.settings', SettingsController::class); + + $router->group([ + 'prefix' => $prefix, + 'as' => "{$name}.", + 'middleware' => $middleware, + self::GUARD_ACTION => $guard, + ], function (Router $router) use ($controller) { + $router->get('/', [$controller, 'index'])->name('index'); + $router->get('/{settings_page}', [$controller, 'edit'])->name('edit')->where('settings_page', '[A-Za-z0-9_-]+'); + $router->patch('/{settings_page}', [$controller, 'update'])->name('update')->where('settings_page', '[A-Za-z0-9_-]+'); + }); + } + + /** + * The guard the current route was registered for, or null for the default guard + */ + public static function guard(Request $request): ?string + { + return $request->route()?->getAction(self::GUARD_ACTION); + } + + /** + * The route name prefix of the current route, e.g. `admin.settings` + */ + public static function namePrefix(Request $request): string + { + $name = (string) $request->route()?->getName(); + + return str_contains($name, '.') + ? substr($name, 0, strrpos($name, '.')) + : 'settings'; + } +} diff --git a/src/SettingsRegistry.php b/src/SettingsRegistry.php new file mode 100644 index 0000000..0c9dc40 --- /dev/null +++ b/src/SettingsRegistry.php @@ -0,0 +1,213 @@ +> */ + protected array $pages = []; + + /** @var array */ + protected array $defined = []; + + /** @var array|null */ + protected ?array $index = null; + + /** @var array|null setting key => page key */ + protected ?array $page_of = null; + + public function __construct( + protected Container $container, + ) {} + + /** + * Register a page class or instance, or get (or start) an inline page by key + * + * @param SettingsPage|class-string|string $page + */ + public function page(SettingsPage|string $page): SettingsPage + { + $this->flushIndex(); + + if ($page instanceof SettingsPage) { + return $this->pages[$page->getKey()] = $page; + } + + if (class_exists($page)) { + $instance = $this->resolve($page); + + return $this->pages[$instance->getKey()] = $instance; + } + + if (isset($this->pages[$page])) { + return $this->find($page); + } + + return $this->pages[$page] = new InlinePage($page); + } + + /** + * Register several page classes + * + * @param array> $pages + */ + public function pages(array $pages): static + { + foreach ($pages as $page) { + $this->page($page); + } + + return $this; + } + + /** + * Define settings that have no page: they have defaults, casts and typed + * access, but no form + * + * @param array|Setting $settings + */ + public function define(array|Setting $settings): static + { + foreach (is_array($settings) ? $settings : [$settings] as $setting) { + $this->defined[$setting->getKey()] = $setting; + } + + $this->flushIndex(); + + return $this; + } + + /** + * The pages users can see, in order + * + * @return array + */ + public function all(): array + { + $pages = []; + + foreach (array_keys($this->pages) as $key) { + $page = $this->find($key); + + if ($page->isVisible()) { + $pages[$key] = $page; + } + } + + $positions = array_flip(array_keys($pages)); + + uksort($pages, fn (string $a, string $b) => [$pages[$a]->getOrder(), $positions[$a]] <=> [$pages[$b]->getOrder(), $positions[$b]]); + + return $pages; + } + + public function find(string $key): ?SettingsPage + { + $page = $this->pages[$key] ?? null; + + if (is_string($page)) { + $page = $this->pages[$key] = $this->resolve($page); + } + + return $page; + } + + public function findOrFail(string $key): SettingsPage + { + return $this->find($key) ?? throw SettingsException::pageNotFound($key); + } + + public function hasPage(string $key): bool + { + return isset($this->pages[$key]); + } + + /** + * Every defined setting, keyed by setting key + * + * @return array + */ + public function settings(): array + { + if ($this->index !== null) { + return $this->index; + } + + $index = $this->defined; + $page_of = []; + + foreach (array_keys($this->pages) as $page_key) { + foreach ($this->find($page_key)->settings() as $key => $setting) { + if (isset($page_of[$key])) { + throw SettingsException::duplicateKey($key, $page_key, $page_of[$key]); + } + + $index[$key] = $setting; + $page_of[$key] = $page_key; + } + } + + $this->page_of = $page_of; + + return $this->index = $index; + } + + public function setting(string $key): ?Setting + { + return $this->settings()[$key] ?? null; + } + + public function has(string $key): bool + { + return $this->setting($key) !== null; + } + + /** + * The page a setting is on + */ + public function pageOf(string $key): ?SettingsPage + { + $this->settings(); + + $page = $this->page_of[$key] ?? null; + + return $page === null ? null : $this->find($page); + } + + /** + * Forget every page and setting + */ + public function flush(): void + { + $this->pages = []; + $this->defined = []; + $this->flushIndex(); + } + + protected function flushIndex(): void + { + $this->index = null; + $this->page_of = null; + } + + /** + * @param class-string $class + */ + protected function resolve(string $class): SettingsPage + { + if (! is_a($class, SettingsPage::class, true)) { + throw SettingsException::invalidPage($class); + } + + return $this->container->make($class); + } +} diff --git a/src/SettingsRepository.php b/src/SettingsRepository.php new file mode 100644 index 0000000..484e9e6 --- /dev/null +++ b/src/SettingsRepository.php @@ -0,0 +1,680 @@ +|null the stored values, as the store keeps them */ + protected ?array $values = null; + + /** The cache version the values were loaded at */ + protected ?string $version = null; + + protected ?ArrayStore $fake = null; + + /** @var array */ + protected array $default_overrides = []; + + public function __construct( + protected Container $app, + protected SettingsRegistry $registry, + protected StoreManager $stores, + ) {} + + /* + |-------------------------------------------------------------------------- + | Reading + |-------------------------------------------------------------------------- + */ + + /** + * The value of a setting: the stored value, else its default (from its + * definition, then config('defaults')), else $default + */ + public function get(string $key, mixed $default = null): mixed + { + $stored = $this->stored(); + + if (! array_key_exists($key, $stored)) { + return $this->default($key) ?? value($default); + } + + return $this->read($key, $stored[$key]); + } + + /** + * The value of a translatable setting in a locale + */ + public function translate(string $key, ?string $locale = null, bool $fallback = true): mixed + { + $stored = $this->stored(); + + if (! array_key_exists($key, $stored)) { + return $this->default($key); + } + + $value = $this->decrypt($key, $stored[$key]); + + if (Translations::isLocaleMap($value)) { + $value = Translations::pick($value, $locale, $fallback); + } + + return $this->registry->setting($key)?->castValue($value) ?? $value; + } + + /** + * Every value of a translatable setting, keyed by locale + * + * @return array + */ + public function translations(string $key): array + { + $stored = $this->stored(); + + $value = array_key_exists($key, $stored) + ? $this->decrypt($key, $stored[$key]) + : $this->default($key); + + if (Translations::isLocaleMap($value)) { + return $value; + } + + return $value === null ? [] : [Translations::defaultLocale() => $value]; + } + + /** + * The default value of a setting + */ + public function default(string $key): mixed + { + if (array_key_exists($key, $this->default_overrides)) { + return $this->default_overrides[$key]; + } + + $setting = $this->registry->setting($key); + + $default = $setting?->getDefault(); + + if ($default !== null) { + return $default; + } + + $default = $this->app['config']->get("defaults.{$key}"); + + return $setting && $default !== null ? $setting->castValue($default) : $default; + } + + /** + * Whether the setting has a stored value + */ + public function has(string $key): bool + { + return array_key_exists($key, $this->stored()); + } + + /** + * Every setting: the defined ones, the ones with a stored value, and the + * ones in config('defaults') + * + * @return array + */ + public function all(): array + { + $keys = array_unique([ + ...array_keys($this->registry->settings()), + ...array_keys($this->stored()), + ...array_keys((array) $this->app['config']->get('defaults', [])), + ]); + + $values = []; + + foreach ($keys as $key) { + $values[$key] = $this->get($key); + } + + return $values; + } + + /** + * The values of some settings + * + * @param array $keys + * @return array + */ + public function only(array $keys): array + { + $values = []; + + foreach ($keys as $key) { + $values[$key] = $this->get($key); + } + + return $values; + } + + /** + * The stored values as the store keeps them (encrypted settings stay encrypted), keyed by setting key + * + * @return array + */ + public function stored(): array + { + if ($this->values === null) { + $this->load(); + } + + return $this->values; + } + + public function string(string $key, ?string $default = null): string + { + $value = $this->get($key, $default); + + if (! is_string($value)) { + throw SettingsException::wrongType($key, 'string', $value); + } + + return $value; + } + + public function integer(string $key, ?int $default = null): int + { + $value = $this->get($key, $default); + + if (! is_int($value)) { + throw SettingsException::wrongType($key, 'int', $value); + } + + return $value; + } + + public function float(string $key, ?float $default = null): float + { + $value = $this->get($key, $default); + + if (is_int($value)) { + return (float) $value; + } + + if (! is_float($value)) { + throw SettingsException::wrongType($key, 'float', $value); + } + + return $value; + } + + public function boolean(string $key, ?bool $default = null): bool + { + $value = $this->get($key, $default); + + if (! is_bool($value)) { + throw SettingsException::wrongType($key, 'bool', $value); + } + + return $value; + } + + /** + * @param array|null $default + * @return array + */ + public function array(string $key, ?array $default = null): array + { + $value = $this->get($key, $default); + + if (! is_array($value)) { + throw SettingsException::wrongType($key, 'array', $value); + } + + return $value; + } + + /** + * The URL of a file setting: an upload on config('settings.disk'), or a + * path in public/ (for defaults such as img/logo.png) + */ + public function url(string $key): ?string + { + $path = $this->get($key); + + if (! is_string($path) || $path === '') { + return null; + } + + if (Str::startsWith($path, ['http://', 'https://', '//', 'data:'])) { + return $path; + } + + $disk = $this->app['config']->get('settings.disk'); + + if ($path !== $this->default($key) && Storage::disk($disk)->exists($path)) { + return Storage::disk($disk)->url($path); + } + + return asset($path); + } + + /* + |-------------------------------------------------------------------------- + | Writing + |-------------------------------------------------------------------------- + */ + + /** + * Store values and tell other processes they changed + * + * @param string|array $key + * @param Model|null $causer who made the change, for the activity log; the + * authenticated user when null + */ + public function set(string|array $key, mixed $value = null, ?Model $causer = null): static + { + $values = is_array($key) ? $key : [$key => $value]; + + if ($values === []) { + return $this; + } + + $old = $this->only(array_keys($values)); + + $storable = []; + + foreach ($values as $setting_key => $setting_value) { + $storable[$setting_key] = $this->storable($setting_key, $setting_value); + } + + $this->store()->put($storable); + + $this->changed($old, $causer); + + return $this; + } + + /** + * Remove stored values, so the settings are back to their defaults + * + * @param string|array $keys + */ + public function forget(string|array $keys, ?Model $causer = null): static + { + $keys = (array) $keys; + + if ($keys === []) { + return $this; + } + + $old = $this->only($keys); + + $this->store()->forget($keys); + + $this->changed($old, $causer); + + return $this; + } + + /** + * Remove every stored value + */ + public function flush(): static + { + $this->store()->flush(); + + $this->touch(); + + return $this; + } + + /* + |-------------------------------------------------------------------------- + | Long-running processes + |-------------------------------------------------------------------------- + */ + + /** + * Reload the values if another process changed them since they were loaded. + * Queue workers call it before each job and Octane before each request. + * + * @return bool whether the values were stale + */ + public function refreshIfStale(): bool + { + if ($this->values === null) { + return false; + } + + $cache = $this->cache(); + + if ($cache !== null && $cache->get($this->versionKey()) === $this->version) { + return false; + } + + $this->refresh(); + + return true; + } + + /** + * Forget the loaded values, so they're read again when next needed + */ + public function refresh(): static + { + $this->values = null; + $this->version = null; + + return $this; + } + + /** + * The version the values were loaded at, or null when they aren't loaded + * or there's no cache + */ + public function version(): ?string + { + return $this->version; + } + + /** + * Mark the settings as changed: other processes reload them + */ + public function touch(): static + { + $cache = $this->cache(); + + if ($cache !== null) { + $previous = $cache->get($this->versionKey()); + + if (is_string($previous)) { + $cache->forget($this->valuesKey($previous)); + } + + $cache->forever($this->versionKey(), (string) Str::uuid()); + } + + return $this->refresh(); + } + + /* + |-------------------------------------------------------------------------- + | Definitions + |-------------------------------------------------------------------------- + */ + + /** + * Register a page class or instance, or get (or start) an inline page by key + * + * @param SettingsPage|class-string|string $page + * @return ($page is string ? InlinePage|SettingsPage : SettingsPage) + */ + public function page(SettingsPage|string $page): SettingsPage + { + return $this->registry->page($page); + } + + /** + * Define settings that have no page + * + * @param array|Setting $settings + */ + public function define(array|Setting $settings): static + { + $this->registry->define($settings); + + return $this; + } + + /** + * @return array + */ + public function pages(): array + { + return $this->registry->all(); + } + + public function definition(string $key): ?Setting + { + return $this->registry->setting($key); + } + + public function registry(): SettingsRegistry + { + return $this->registry; + } + + /* + |-------------------------------------------------------------------------- + | Stores + |-------------------------------------------------------------------------- + */ + + public function store(): SettingsStore + { + return $this->fake ?? $this->stores->store(); + } + + /** + * Add a store driver + * + * @param Closure(Container, array): SettingsStore $creator + */ + public function extend(string $driver, Closure $creator): static + { + $this->stores->extend($driver, $creator); + + return $this; + } + + /* + |-------------------------------------------------------------------------- + | Testing + |-------------------------------------------------------------------------- + */ + + /** + * Keep values in memory, without the store or the cache, starting with these + * + * @param array $values + */ + public function fake(array $values = []): static + { + $this->fake ??= new ArrayStore; + + $storable = []; + + foreach ($values as $key => $value) { + $storable[$key] = $this->storable($key, $value); + } + + $this->fake->put($storable); + + return $this->refresh(); + } + + public function isFaking(): bool + { + return $this->fake !== null; + } + + /** + * Use this default for a setting, whatever its definition says + */ + public function fakeDefault(string $key, mixed $value): static + { + $this->default_overrides[$key] = $value; + + return $this; + } + + /** + * Stop faking values and defaults + */ + public function stopFaking(): static + { + $this->fake = null; + $this->default_overrides = []; + + return $this->refresh(); + } + + /* + |-------------------------------------------------------------------------- + | Internals + |-------------------------------------------------------------------------- + */ + + protected function load(): void + { + $cache = $this->cache(); + + if ($cache === null) { + $this->values = $this->store()->all(); + $this->version = null; + + return; + } + + $version = $cache->get($this->versionKey()); + + if (! is_string($version)) { + $version = (string) Str::uuid(); + + $cache->forever($this->versionKey(), $version); + } + + $this->version = $version; + + $this->values = $cache->remember($this->valuesKey($version), now()->addWeek(), fn () => $this->store()->all()); + } + + /** + * After a write: tell other processes, and fire SettingsUpdated if anything changed + * + * @param array $old + */ + protected function changed(array $old, ?Model $causer): void + { + $this->touch(); + + $new = $this->only(array_keys($old)); + + if ($new == $old) { + return; + } + + $causer ??= $this->authenticatedUser(); + + $this->app['events']->dispatch(new SettingsUpdated($old, $new, $causer)); + } + + /** + * A stored value as the app reads it + */ + protected function read(string $key, mixed $value): mixed + { + $value = $this->decrypt($key, $value); + + $setting = $this->registry->setting($key); + + if ($setting === null) { + return $value; + } + + if ($setting->isDeclaredTranslatable() && Translations::isLocaleMap($value)) { + $value = Translations::pick($value); + } + + return $setting->castValue($value); + } + + protected function storable(string $key, mixed $value): mixed + { + $setting = $this->registry->setting($key); + + if ($setting === null) { + return $value; + } + + $value = $setting->storableValue($value); + + if ($setting->isEncrypted() && $value !== null) { + return $this->app['encrypter']->encrypt($value); + } + + return $value; + } + + protected function decrypt(string $key, mixed $value): mixed + { + if (! is_string($value) || ! $this->registry->setting($key)?->isEncrypted()) { + return $value; + } + + try { + return $this->app['encrypter']->decrypt($value); + } catch (DecryptException) { + return $value; + } + } + + protected function cache(): ?Cache + { + if ($this->fake !== null) { + return null; + } + + $config = $this->app['config']; + + if (! $config->get('settings.cache.enabled', true)) { + return null; + } + + return $this->app['cache']->store($config->get('settings.cache.store')); + } + + protected function versionKey(): string + { + return $this->cachePrefix() . '.version'; + } + + protected function valuesKey(string $version): string + { + return $this->cachePrefix() . ".values.{$version}"; + } + + protected function cachePrefix(): string + { + return (string) $this->app['config']->get('settings.cache.prefix', 'javaabu_settings'); + } + + protected function authenticatedUser(): ?Model + { + if (! $this->app->bound('auth')) { + return null; + } + + $user = $this->app['auth']->user(); + + return $user instanceof Model ? $user : null; + } +} diff --git a/src/SettingsServiceProvider.php b/src/SettingsServiceProvider.php index 085711e..b4841a9 100644 --- a/src/SettingsServiceProvider.php +++ b/src/SettingsServiceProvider.php @@ -2,36 +2,196 @@ namespace Javaabu\Settings; +use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; -use anlutro\LaravelSettings\ServiceProvider as AluntroSettingsServiceProvider; -use Javaabu\Settings\Providers\EventServiceProvider; +use Illuminate\Support\Str; +use Javaabu\Manifest\Concerns\RegistersAssets; +use Javaabu\Manifest\Package; +use Javaabu\Permissions\Permissions; +use Javaabu\Settings\Commands\ImportV1SettingsCommand; +use Javaabu\Settings\Definitions\SettingsPage; +use Javaabu\Settings\Events\SettingsUpdated; +use Javaabu\Settings\Facades\Settings; +use Javaabu\Settings\Listeners\LogSettingsUpdate; +use Javaabu\Settings\Policies\SettingsPolicy; +use Javaabu\Settings\Routing\SettingsRoutes; +use Javaabu\Settings\Stores\StoreManager; class SettingsServiceProvider extends ServiceProvider { - /** - * Bootstrap the application services. - */ + use RegistersAssets; + + /** The Octane events after which a worker checks for changed settings */ + public const array OCTANE_EVENTS = [ + 'Laravel\Octane\Events\RequestReceived', + 'Laravel\Octane\Events\TaskReceived', + 'Laravel\Octane\Events\TickReceived', + ]; + + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../config/settings.php', 'settings'); + + $this->app->singleton(StoreManager::class); + $this->app->singleton(SettingsRegistry::class); + $this->app->singleton(SettingsRepository::class); + $this->app->alias(SettingsRepository::class, 'settings'); + } + public function boot(): void { - // declare publishes + $this->loadJsonTranslationsFrom(__DIR__ . '/../lang'); + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'settings'); + + $this->registerFrontendAssets(); + $this->registerPages(); + $this->registerRouteMacros(); + $this->registerPermissions(); + $this->registerRefreshing(); + $this->registerActivityLog(); + if ($this->app->runningInConsole()) { - $this->publishes([ - __DIR__ . '/../config/defaults.php' => config_path('defaults.php'), - ], 'settings-defaults'); + if (Settings::$runs_migrations) { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + + $this->registerPublishing(); + + $this->commands([ + ImportV1SettingsCommand::class, + ]); } } /** - * Register the application services. + * The Inertia page (settings::Settings/Edit) and the components apps can + * import as @javaabu/settings */ - public function register(): void + protected function registerFrontendAssets(): void + { + $this->registerAssets('settings', __DIR__ . '/../resources', function (Package $package) { + $package + ->js() + ->pages() + ->npm([ + '@javaabu/forms' => 'github:Javaabu/react-forms#next-gen', + '@javaabu/onu' => '^0.2.1', + ], peer_dependencies: [ + 'react' => '^19.0', + 'react-dom' => '^19.0', + '@inertiajs/react' => '^2.0 || ^3.0', + ]); + }); + } + + protected function registerPages(): void + { + $pages = (array) config('settings.pages', []); + + if ($pages !== []) { + $this->app->make(SettingsRegistry::class)->pages($pages); + } + } + + protected function registerRouteMacros(): void + { + Route::macro('settings', function (array $options = []) { + SettingsRoutes::register($this, $options); + }); + } + + /** + * view_settings and edit_settings, the permissions of pages that have their + * own, and the default policy, which the app can override + */ + protected function registerPermissions(): void + { + if (! config('settings.register_permissions', true)) { + return; + } + + Permissions::register('settings', [ + SettingsPolicy::VIEW => 'View settings', + SettingsPolicy::EDIT => 'Edit settings', + ], label: 'Settings'); + + Permissions::policy(SettingsPage::class, config('settings.policy', SettingsPolicy::class)); + + $this->app->booted(fn () => $this->registerPagePermissions()); + } + + /** + * Pages registered by the app may need their own permission + */ + protected function registerPagePermissions(): void + { + $group = Permissions::group('settings'); + + foreach ($this->app->make(SettingsRegistry::class)->all() as $page) { + $permission = $page->getPermission(); + + if ($permission === null || Permissions::has($permission)) { + continue; + } + + $label = Str::headline($page->getKey()); + + $group?->add([$permission => "Edit {$label} settings"]); + } + } + + /** + * Queue workers (before each job, as Queue::before() would) and Octane + * reload settings another process changed + */ + protected function registerRefreshing(): void { - // Load the original settings package - $this->app->register(AluntroSettingsServiceProvider::class); + $refresh = fn () => $this->app->make(SettingsRepository::class)->refreshIfStale(); + + if (config('settings.refresh.queue', true)) { + Event::listen(JobProcessing::class, $refresh); + } + + if (config('settings.refresh.octane', true)) { + Event::listen(self::OCTANE_EVENTS, $refresh); + } + } + + protected function registerActivityLog(): void + { + if (! config('settings.log_activity', true)) { + return; + } + + if (! function_exists('activity')) { + return; + } + + Event::listen(SettingsUpdated::class, LogSettingsUpdate::class); + } + + protected function registerPublishing(): void + { + $this->publishes([ + __DIR__ . '/../config/settings.php' => config_path('settings.php'), + ], 'settings-config'); + + $this->publishes([ + __DIR__ . '/../database/migrations' => database_path('migrations'), + ], 'settings-migrations'); + + $this->publishes([ + __DIR__ . '/../lang' => lang_path('vendor/settings'), + ], 'settings-translations'); - $this->app->register(EventServiceProvider::class); + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/settings'), + ], 'settings-views'); - // Require helpers defined on the package. - require_once __DIR__ . '/helpers/helpers.php'; + $this->publishes([ + __DIR__ . '/../config/defaults.php' => config_path('defaults.php'), + ], 'settings-defaults'); } } diff --git a/src/Stores/ArrayStore.php b/src/Stores/ArrayStore.php new file mode 100644 index 0000000..def02f5 --- /dev/null +++ b/src/Stores/ArrayStore.php @@ -0,0 +1,40 @@ + $values + */ + public function __construct( + protected array $values = [], + ) {} + + public function all(): array + { + return $this->values; + } + + public function put(array $values): void + { + $this->values = array_merge($this->values, $values); + } + + public function forget(array $keys): void + { + foreach ($keys as $key) { + unset($this->values[$key]); + } + } + + public function flush(): void + { + $this->values = []; + } +} diff --git a/src/Stores/DatabaseStore.php b/src/Stores/DatabaseStore.php new file mode 100644 index 0000000..677ab82 --- /dev/null +++ b/src/Stores/DatabaseStore.php @@ -0,0 +1,73 @@ +query() + ->pluck('value', 'key') + ->map(fn (?string $value) => $value === null ? null : json_decode($value, true)) + ->all(); + } + + public function put(array $values): void + { + if ($values === []) { + return; + } + + $now = Date::now(); + + $rows = []; + + foreach ($values as $key => $value) { + $rows[] = [ + 'key' => (string) $key, + 'value' => json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + $this->query()->upsert($rows, ['key'], ['value', 'updated_at']); + } + + public function forget(array $keys): void + { + if ($keys === []) { + return; + } + + $this->query()->whereIn('key', $keys)->delete(); + } + + public function flush(): void + { + $this->query()->delete(); + } + + public function table(): string + { + return $this->table; + } + + protected function query(): Builder + { + return $this->connection->table($this->table); + } +} diff --git a/src/Stores/JsonStore.php b/src/Stores/JsonStore.php new file mode 100644 index 0000000..c45b6c8 --- /dev/null +++ b/src/Stores/JsonStore.php @@ -0,0 +1,67 @@ +files->exists($this->path)) { + return []; + } + + $values = json_decode($this->files->get($this->path), true); + + if (! is_array($values)) { + throw SettingsException::invalidJsonFile($this->path); + } + + return $values; + } + + public function put(array $values): void + { + $this->write(array_merge($this->all(), $values)); + } + + public function forget(array $keys): void + { + $this->write(array_diff_key($this->all(), array_flip($keys))); + } + + public function flush(): void + { + $this->write([]); + } + + public function path(): string + { + return $this->path; + } + + /** + * @param array $values + */ + protected function write(array $values): void + { + $this->files->ensureDirectoryExists(dirname($this->path)); + + $json = json_encode($values === [] ? new stdClass : $values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + $this->files->replace($this->path, $json); + } +} diff --git a/src/Stores/StoreManager.php b/src/Stores/StoreManager.php new file mode 100644 index 0000000..45b2899 --- /dev/null +++ b/src/Stores/StoreManager.php @@ -0,0 +1,67 @@ + new RedisStore(...))`. + * + * @method SettingsStore driver(?string $driver = null) + */ +class StoreManager extends Manager +{ + public function getDefaultDriver(): string + { + return $this->config->get('settings.store', 'database'); + } + + public function store(?string $name = null): SettingsStore + { + return $this->driver($name); + } + + protected function createDatabaseDriver(): DatabaseStore + { + $config = $this->storeConfig('database'); + + return new DatabaseStore( + $this->container->make('db')->connection($config['connection'] ?? null), + $config['table'] ?? 'settings', + ); + } + + protected function createJsonDriver(): JsonStore + { + $config = $this->storeConfig('json'); + + return new JsonStore( + $this->container->make(Filesystem::class), + $config['path'] ?? storage_path('app/settings.json'), + ); + } + + protected function createArrayDriver(): ArrayStore + { + return new ArrayStore; + } + + /** + * Custom creators get the app and the store's config + */ + protected function callCustomCreator($driver): mixed + { + return $this->customCreators[$driver]($this->container, $this->storeConfig($driver)); + } + + /** + * @return array + */ + protected function storeConfig(string $name): array + { + return (array) $this->config->get("settings.stores.{$name}", []); + } +} diff --git a/src/Support/Translatable/SettingsRecord.php b/src/Support/Translatable/SettingsRecord.php new file mode 100644 index 0000000..96318f4 --- /dev/null +++ b/src/Support/Translatable/SettingsRecord.php @@ -0,0 +1,92 @@ + $values setting key => value; translatable settings as locale => value + */ + public static function fromValues(array $values): static + { + $record = new static; + $default = Translations::defaultLocale(); + + $attributes = ['lang' => $default]; + $translations = []; + + foreach ($values as $key => $value) { + if (! in_array($key, $record->getTranslatables(), true)) { + $attributes[$key] = $value; + + continue; + } + + if (! Translations::isLocaleMap($value)) { + $attributes[$key] = $value === [] ? null : $value; + + continue; + } + + $attributes[$key] = $value[$default] ?? null; + + foreach ($value as $locale => $translation) { + if ($locale !== $default) { + $translations[$locale][$key] = $translation; + } + } + } + + $attributes['translations'] = json_encode($translations, JSON_UNESCAPED_UNICODE); + + $record->setRawAttributes($attributes); + + return $record; + } + + /** + * The keys of every setting declared translatable + * + * @return array + */ + public function getTranslatables(): array + { + $settings = app(SettingsRegistry::class)->settings(); + + return array_keys(array_filter($settings, fn (Setting $setting) => $setting->isDeclaredTranslatable())); + } + + /** + * Values are kept in the default locale + */ + public function primaryLocale(): string + { + return $this->getAttributeValue('lang') ?: Translations::defaultLocale(); + } + + public function getFieldsIgnoredForTranslation(): array + { + return ['id', 'translations', 'lang']; + } +} diff --git a/src/Support/Translations.php b/src/Support/Translations.php new file mode 100644 index 0000000..06a50d9 --- /dev/null +++ b/src/Support/Translations.php @@ -0,0 +1,115 @@ +getLocale(); + } + + /** + * The locale a translatable value falls back to + */ + public static function defaultLocale(): string + { + return static::enabled() + ? Translatable::defaultLocale() + : (string) config('app.fallback_locale', config('app.locale', 'en')); + } + + /** + * The codes of the active locales, the default first + * + * @return array + */ + public static function locales(): array + { + if (! static::enabled()) { + return array_values(array_unique([static::defaultLocale(), app()->getLocale()])); + } + + return Translatable::locales()->map(fn ($locale) => $locale->code)->values()->all(); + } + + /** + * Whether a stored value is a map of locale => value + */ + public static function isLocaleMap(mixed $value): bool + { + if (! is_array($value) || $value === [] || array_is_list($value)) { + return false; + } + + foreach (array_keys($value) as $key) { + if (! is_string($key) || ! preg_match('/^[a-z]{2,3}([_-][A-Za-z0-9]+)?$/', $key)) { + return false; + } + } + + return true; + } + + /** + * The value of a locale map in the locale, falling back to the default + * locale, then the first value that isn't empty + * + * @param array $values + */ + public static function pick(array $values, ?string $locale = null, bool $fallback = true): mixed + { + $locale ??= static::currentLocale(); + + if (static::filled($values[$locale] ?? null)) { + return $values[$locale]; + } + + if (! $fallback) { + return null; + } + + $default = static::defaultLocale(); + + if (static::filled($values[$default] ?? null)) { + return $values[$default]; + } + + foreach ($values as $value) { + if (static::filled($value)) { + return $value; + } + } + + return null; + } + + protected static function filled(mixed $value): bool + { + return $value !== null && $value !== ''; + } +} diff --git a/src/Testing/FakesSettings.php b/src/Testing/FakesSettings.php index ffe49bc..32df247 100644 --- a/src/Testing/FakesSettings.php +++ b/src/Testing/FakesSettings.php @@ -2,39 +2,41 @@ namespace Javaabu\Settings\Testing; -use anlutro\LaravelSettings\Facades\Setting; +use Javaabu\Settings\Facades\Settings; +/** + * Fake settings and defaults in tests. Values are kept in memory, so nothing is + * written to the store or the cache. + */ trait FakesSettings { - protected array $fake_settings = []; /** - * Fake a setting default + * Fake the default value of a setting */ - protected function setFakeDefaultSetting(string $setting, $value) + protected function setFakeDefaultSetting(string $setting, mixed $value): void { - $this->app['config']->set('defaults.' . $setting, $value); + $this->app['config']->set("defaults.{$setting}", $value); + + Settings::fakeDefault($setting, $value); } /** - * Fake a setting + * Fake the value of a setting, and its default ($default, null when not given) */ - protected function setFakeSetting(string $setting, $value, $default = null) + protected function setFakeSetting(string $setting, mixed $value, mixed $default = null): void { - $this->app['config']->set('defaults.' . $setting, $default); - - // mock only once - if (empty($this->fake_settings)) { - Setting::shouldReceive('get') - ->andReturnUsing(function ($key) { - if (array_key_exists($key, $this->fake_settings)) { - return $this->fake_settings[$key]; - } + $this->setFakeDefaultSetting($setting, $default); - return default_setting($key); - }); - } + Settings::fake([$setting => $value]); + } - // register the fake setting - $this->fake_settings[$setting] = $value; + /** + * Fake several settings + * + * @param array $settings + */ + protected function setFakeSettings(array $settings): void + { + Settings::fake($settings); } } diff --git a/src/Views/ViewRenderer.php b/src/Views/ViewRenderer.php new file mode 100644 index 0000000..798ea12 --- /dev/null +++ b/src/Views/ViewRenderer.php @@ -0,0 +1,64 @@ + $data + */ + public function render(string $page, array $data = []): View|Responsable + { + if ($this->stack() === self::INERTIA) { + return $this->renderInertia($page, $data); + } + + return view("settings::{$page}", $data); + } + + public function stack(): string + { + return config('settings.views', self::BLADE); + } + + public function isInertia(): bool + { + return $this->stack() === self::INERTIA; + } + + /** + * The Inertia page name for a page: `settings.edit` → `settings::Settings/Edit` + */ + public function inertiaPage(string $page): string + { + $segments = array_map(fn (string $segment) => str($segment)->studly()->toString(), explode('.', $page)); + + return 'settings::' . implode('/', $segments); + } + + /** + * @param array $data + */ + protected function renderInertia(string $page, array $data): Responsable + { + if (! class_exists(Inertia::class)) { + throw new RuntimeException('Install inertiajs/inertia-laravel to use the inertia views, or set settings.views to blade.'); + } + + return Inertia::render($this->inertiaPage($page), $data); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..bece638 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,128 @@ + 'ރާއްޖެ']); // set + * settings()->boolean('registration_open'); // typed + * ``` + * + * @param string|array|null $key + */ + function settings(string|array|null $key = null, mixed $default = null): mixed + { + $settings = app(SettingsRepository::class); + + if ($key === null) { + return $settings; + } + + if (is_array($key)) { + return $settings->set($key); + } + + return $settings->get($key, $default); + } +} + +if (! function_exists('default_setting')) { + /** + * The default value of a setting: its definition's default, or config('defaults.{key}') + */ + function default_setting(string $key): mixed + { + return app(SettingsRepository::class)->default($key); + } +} + +if (! function_exists('get_setting')) { + /** + * The value of a setting, or its default. Same as settings($key) + */ + function get_setting(string $key): mixed + { + return app(SettingsRepository::class)->get($key); + } +} + +if (! function_exists('set_file_setting')) { + /** + * Saves the file uploaded in the current request under the setting's key, moved + * into public $path. An empty value in the request resets the setting to its default. + * + * Kept from v1. Settings pages upload file settings for you. + */ + function set_file_setting(string $key, string $path = 'storage/uploads'): bool + { + if (RequestFacade::hasFile($key) && RequestFacade::file($key)->isValid()) { + $file = RequestFacade::file($key); + $extension = $file->guessExtension(); + $file_name = 'custom-' . $key . '-' . strtolower(Str::random(8)) . '.' . $extension; + + $file->move(public_path($path), $file_name); + + app(SettingsRepository::class)->set($key, "{$path}/{$file_name}"); + + return true; + } + + if (RequestFacade::exists($key)) { + reset_file_setting($key); + + return true; + } + + return false; + } +} + +if (! function_exists('reset_file_setting')) { + /** + * Deletes the uploaded file of a file setting (from the public disk) and resets + * the setting to its default. Kept from v1. + */ + function reset_file_setting(string $key): void + { + $settings = app(SettingsRepository::class); + + $current_file = $settings->get($key); + $default = $settings->default($key); + + if ($current_file == $default) { + return; + } + + if (is_string($current_file)) { + $relative = ltrim(remove_prefix('storage', $current_file), '/'); + + if (Storage::disk('public')->exists($relative)) { + Storage::disk('public')->delete($relative); + } + } + + $settings->forget($key); + } +} + +if (! function_exists('remove_prefix')) { + /** + * Remove the prefix from the start of a string. Kept from v1. + */ + function remove_prefix(string $needle, string $haystack): string + { + if (str_starts_with($haystack, $needle)) { + return substr($haystack, strlen($needle)); + } + + return $haystack; + } +} diff --git a/src/helpers/helpers.php b/src/helpers/helpers.php deleted file mode 100644 index 5bbcd67..0000000 --- a/src/helpers/helpers.php +++ /dev/null @@ -1,105 +0,0 @@ -isValid()) { - $extension = RequestFacade::file($key)->guessExtension(); //get file extension - $file_name = 'custom-'.$key.'-'.strtolower(Str::random(8)).'.'.$extension; // renaming file - RequestFacade::file($key)->move($path, $file_name); // uploading file to given path - Setting::set($key, $path.'/'.$file_name); - return true; - } elseif (RequestFacade::exists($key)) { - reset_file_setting($key); - return true; - } - - return false; - } -} - -if (! function_exists('reset_file_setting')) { - /** - * Resets a file settings - * - * @param string $key - * - * @throws FilesystemException - */ - function reset_file_setting(string $key): void - { - $current_file = get_setting($key); - $default = default_setting($key); - - //delete the custom file if different from default - if ($current_file != $default) { - // get relative path - $current_file = remove_prefix('storage', $current_file); - - if (Storage::disk('public')->exists($current_file)) { - Storage::disk('public')->delete($current_file); - } - - Setting::set($key, $default); - } - } -} - -if (! function_exists('remove_prefix')) { - /** - * Remove the given prefix from a string - * @param $needle - * @param $haystack - * @return bool|string - */ - function remove_prefix($needle, $haystack): bool|string - { - if (str_starts_with($haystack, $needle)) { - $haystack = substr($haystack, strlen($needle)); - } - - return $haystack; - } -} - diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..deef214 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,12 @@ +# For `vendor/bin/testbench`: the queue worker the refresh tests start in its own process +providers: + - Spatie\Permission\PermissionServiceProvider + - Javaabu\Permissions\PermissionsServiceProvider + - Javaabu\Manifest\ManifestServiceProvider + - Javaabu\Forms\FormsServiceProvider + - Javaabu\Stencil\StencilServiceProvider + - Javaabu\Settings\SettingsServiceProvider + +env: + - APP_KEY="base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE=" + - SETTINGS_STORE=database diff --git a/tests/Concerns/InteractsWithTranslatable.php b/tests/Concerns/InteractsWithTranslatable.php new file mode 100644 index 0000000..022b6a3 --- /dev/null +++ b/tests/Concerns/InteractsWithTranslatable.php @@ -0,0 +1,52 @@ + $codes + */ + protected function setUpLanguages(array $codes = ['en', 'dv']): void + { + if (! Schema::hasTable('languages')) { + $package = dirname((new ReflectionClass(Language::class))->getFileName(), 3); + + $migration = require "{$package}/database/migrations/create_languages_table.php"; + + $migration->up(); + } + + foreach ($codes as $code) { + Language::query()->firstOrCreate(['code' => $code], LanguagePresets::get($code)); + } + + app(LanguageRegistrar::class)->forgetCachedLanguages(); + + Languages::setCurrentLocale($codes[0]); + } + + protected function withoutTranslatable(callable $callback): mixed + { + $enabled = config('translatable.enabled'); + + config(['translatable.enabled' => false]); + + try { + return $callback(); + } finally { + config(['translatable.enabled' => $enabled]); + } + } +} diff --git a/tests/Feature/ActivityLogTest.php b/tests/Feature/ActivityLogTest.php new file mode 100644 index 0000000..9071f03 --- /dev/null +++ b/tests/Feature/ActivityLogTest.php @@ -0,0 +1,84 @@ +set('settings.log_activity', true); + } + + protected function setUp(): void + { + parent::setUp(); + + Settings::page(GeneralSettings::class); + Settings::page(MailSettings::class); + } + + #[Test] + public function it_logs_the_changed_settings_with_their_old_values(): void + { + $user = User::create(['name' => 'އަޙްމަދު', 'email' => 'ahmed@example.com', 'password' => 'secret']); + + Settings::set(['site_name' => 'މާލެ', 'per_page' => 20], causer: $user); + + $log = Activity::query()->latest('id')->first(); + + $this->assertEquals('settings', $log->description); + $this->assertEquals(['site_name' => 'މާލެ'], $log->properties['attributes']); + $this->assertEquals(['site_name' => 'ރާއްޖެ'], $log->properties['old']); + $this->assertTrue($log->causer->is($user)); + } + + #[Test] + public function it_uses_the_authenticated_user_as_the_causer(): void + { + $user = User::create(['name' => 'Aisha', 'email' => 'aisha@example.com', 'password' => 'secret']); + + $this->actingAs($user); + + Settings::set('site_name', 'މާލެ'); + + $this->assertDatabaseHas('activity_log', ['description' => 'settings', 'causer_type' => 'user', 'causer_id' => $user->id]); + } + + #[Test] + public function it_hides_the_values_of_encrypted_settings(): void + { + Settings::set('mail_password', 'secret'); + + $log = Activity::query()->latest('id')->first(); + + $this->assertEquals(['mail_password' => LogSettingsUpdate::HIDDEN], $log->properties['attributes']); + $this->assertEquals(['mail_password' => LogSettingsUpdate::HIDDEN], $log->properties['old']); + } + + #[Test] + public function it_doesnt_log_when_logging_is_off(): void + { + $this->app['events']->forget(SettingsUpdated::class); + + Settings::set('site_name', 'މާލެ'); + + $this->assertDatabaseCount('activity_log', 0); + } +} diff --git a/tests/Feature/FrontendAssetsTest.php b/tests/Feature/FrontendAssetsTest.php new file mode 100644 index 0000000..1e87596 --- /dev/null +++ b/tests/Feature/FrontendAssetsTest.php @@ -0,0 +1,33 @@ +package('settings'); + + $this->assertEquals('@javaabu/settings', $package->importAlias()); + $this->assertEquals('js/Pages', $package->pagesPath()); + $this->assertFileExists($package->path('js/Pages/Settings/Edit.tsx')); + $this->assertArrayHasKey('@javaabu/forms', $package->npmDependencies()); + $this->assertArrayHasKey('react', $package->npmPeerDependencies()); + + $this->artisan('manifest:list', ['package' => 'settings'])->assertSuccessful(); + } + + #[Test] + public function it_publishes_its_views_config_migration_and_translations(): void + { + foreach (['settings-config', 'settings-migrations', 'settings-views', 'settings-translations', 'settings-defaults'] as $tag) { + $this->assertNotEmpty(ServiceProvider::pathsToPublish(null, $tag), $tag); + } + } +} diff --git a/tests/Feature/HelpersTest.php b/tests/Feature/HelpersTest.php new file mode 100644 index 0000000..73c2355 --- /dev/null +++ b/tests/Feature/HelpersTest.php @@ -0,0 +1,95 @@ +assertInstanceOf(SettingsRepository::class, settings()); + $this->assertEquals('ރާއްޖެ', settings('site_name')); + $this->assertEquals('fallback', settings('unknown', 'fallback')); + + settings(['site_name' => 'މާލެ']); + + $this->assertEquals('މާލެ', settings('site_name')); + $this->assertSame(20, settings()->integer('per_page')); + } + + #[Test] + public function it_keeps_the_v1_helpers(): void + { + $this->app['config']->set('defaults.app_name', 'Laravel'); + + $this->assertEquals('Laravel', get_setting('app_name')); + $this->assertEquals('Laravel', default_setting('app_name')); + $this->assertEquals('ރާއްޖެ', default_setting('site_name')); + + Settings::set('site_name', 'މާލެ'); + + $this->assertEquals('މާލެ', get_setting('site_name')); + $this->assertEquals('ރާއްޖެ', default_setting('site_name')); + $this->assertEquals('/logo.png', remove_prefix('storage', 'storage/logo.png')); + $this->assertEquals('img/logo.png', remove_prefix('storage', 'img/logo.png')); + } + + #[Test] + public function it_sets_and_resets_file_settings_from_the_request_like_v1(): void + { + Storage::fake('public'); + + $directory = 'javaabu-settings-test-' . uniqid(); + + $this->app['request']->files->set('logo', UploadedFile::fake()->image('logo.png')); + + $this->assertTrue(set_file_setting('logo', $directory)); + + $path = get_setting('logo'); + + $this->assertStringStartsWith("{$directory}/custom-logo-", $path); + $this->assertFileExists(public_path($path)); + + unlink(public_path($path)); + rmdir(public_path($directory)); + + Settings::set('logo', 'storage/settings/logo.png'); + Storage::disk('public')->put('settings/logo.png', 'png'); + + $this->app->instance('request', Request::create('/', 'POST', ['logo' => ''])); + RequestFacade::clearResolvedInstance('request'); + + $this->assertTrue(set_file_setting('logo')); + + Storage::disk('public')->assertMissing('settings/logo.png'); + $this->assertEquals('img/logo.png', get_setting('logo')); + $this->assertFalse(Settings::has('logo')); + } + + #[Test] + public function it_does_nothing_when_the_request_has_no_file_setting(): void + { + $this->assertFalse(set_file_setting('logo')); + + reset_file_setting('logo'); + + $this->assertEquals('img/logo.png', get_setting('logo')); + } +} diff --git a/tests/Feature/ImportV1SettingsCommandTest.php b/tests/Feature/ImportV1SettingsCommandTest.php new file mode 100644 index 0000000..f7f52d2 --- /dev/null +++ b/tests/Feature/ImportV1SettingsCommandTest.php @@ -0,0 +1,81 @@ +increments('id'); + $table->string('key'); + $table->text('value'); + }); + + $this->app['db']->table('persistant_settings')->insert([ + ['key' => 'site_name', 'value' => 'ދިވެހިރާއްޖެ'], + ['key' => 'per_page', 'value' => '25'], + ['key' => 'featured.0', 'value' => 'books'], + ['key' => 'featured.1', 'value' => 'fruits'], + ]); + + Settings::set('registration_open', false); + + $this->artisan('settings:import-v1') + ->expectsOutputToContain('Imported 3 settings.') + ->assertSuccessful(); + + $this->assertEquals('ދިވެހިރާއްޖެ', Settings::get('site_name')); + $this->assertSame(25, Settings::get('per_page')); + $this->assertEquals(['books', 'fruits'], Settings::get('featured')); + } + + #[Test] + public function it_imports_the_v1_json_file_without_overwriting_unless_forced(): void + { + $path = sys_get_temp_dir() . '/javaabu-settings-v1-' . uniqid() . '.json'; + + file_put_contents($path, json_encode(['site_name' => 'v1 name', 'per_page' => '30'])); + + Settings::set('site_name', 'v2 name'); + + $this->artisan('settings:import-v1', ['--from' => 'json', '--path' => $path]) + ->expectsOutputToContain('Skipping `site_name`') + ->assertSuccessful(); + + $this->assertEquals('v2 name', Settings::get('site_name')); + $this->assertSame(30, Settings::get('per_page')); + + $this->artisan('settings:import-v1', ['--from' => 'json', '--path' => $path, '--force' => true])->assertSuccessful(); + + $this->assertEquals('v1 name', Settings::get('site_name')); + + unlink($path); + } + + #[Test] + public function it_fails_when_there_is_nothing_to_import(): void + { + $this->artisan('settings:import-v1')->assertFailed(); + $this->artisan('settings:import-v1', ['--from' => 'json', '--path' => '/missing.json'])->assertFailed(); + $this->artisan('settings:import-v1', ['--from' => 'redis'])->assertFailed(); + } +} diff --git a/tests/Feature/JsonSettingsDriverTest.php b/tests/Feature/JsonSettingsDriverTest.php deleted file mode 100644 index 1d6daca..0000000 --- a/tests/Feature/JsonSettingsDriverTest.php +++ /dev/null @@ -1,24 +0,0 @@ -app['config']->set('settings.store', 'json'); - $this->app['config']->set('settings.defaults.foo', 'bar'); - } - - public function test_it_can_get_app_name_setting() - { - $this->assertEquals('Laravel', get_setting('app_name')); - $this->assertEquals('img/logo.png', get_setting('app_logo')); - $this->assertEquals(2048, get_setting('max_upload_file_size')); - $this->assertEquals(5, get_setting('max_num_files')); - } -} diff --git a/tests/Feature/QueueRefreshTest.php b/tests/Feature/QueueRefreshTest.php new file mode 100644 index 0000000..db8a3e8 --- /dev/null +++ b/tests/Feature/QueueRefreshTest.php @@ -0,0 +1,135 @@ +set('settings.store', 'database'); + $app['config']->set('queue.default', 'database'); + + if ($this->name() === 'it_keeps_stale_values_when_refreshing_is_off') { + $app['config']->set('settings.refresh.queue', false); + } + } + + protected function setUp(): void + { + parent::setUp(); + + RecordSetting::$recorded = []; + + Settings::page(GeneralSettings::class); + } + + #[Test] + public function it_reloads_settings_changed_by_another_process_before_the_next_job(): void + { + RecordSetting::dispatch('site_name'); + $this->work(); + + $this->changeFromAnotherProcess('site_name', 'މާލެ'); + + // the worker's settings are still the ones it loaded + $this->assertEquals('ރާއްޖެ', settings('site_name')); + + RecordSetting::dispatch('site_name'); + $this->work(); + + $this->assertEquals(['ރާއްޖެ', 'މާލެ'], RecordSetting::$recorded); + } + + #[Test] + public function it_keeps_the_loaded_settings_when_nothing_changed(): void + { + RecordSetting::dispatch('site_name'); + $this->work(); + + $version = Settings::version(); + + RecordSetting::dispatch('site_name'); + $this->work(); + + $this->assertEquals($version, Settings::version()); + $this->assertFalse(Settings::refreshIfStale()); + } + + #[Test] + public function it_keeps_stale_values_when_refreshing_is_off(): void + { + RecordSetting::dispatch('site_name'); + $this->work(); + + $this->changeFromAnotherProcess('site_name', 'މާލެ'); + + RecordSetting::dispatch('site_name'); + $this->work(); + + $this->assertEquals(['ރާއްޖެ', 'ރާއްޖެ'], RecordSetting::$recorded); + } + + #[Test] + public function it_reloads_before_every_job_without_a_cache(): void + { + $this->app['config']->set('settings.cache.enabled', false); + + RecordSetting::dispatch('site_name'); + $this->work(); + + $this->changeFromAnotherProcess('site_name', 'މާލެ'); + + RecordSetting::dispatch('site_name'); + $this->work(); + + $this->assertEquals(['ރާއްޖެ', 'މާލެ'], RecordSetting::$recorded); + } + + #[Test] + public function it_reloads_settings_on_octane_requests(): void + { + $this->assertEquals('ރާއްޖެ', settings('site_name')); + + $this->changeFromAnotherProcess('site_name', 'މާލެ'); + + $this->assertEquals('ރާއްޖެ', settings('site_name')); + + $this->app['events']->dispatch(SettingsServiceProvider::OCTANE_EVENTS[0], [new stdClass]); + + $this->assertEquals('މާލެ', settings('site_name')); + } + + protected function work(): void + { + $this->app['queue.worker']->runNextJob('database', 'default', new WorkerOptions(sleep: 0, maxTries: 1)); + } + + /** + * Write with a repository of its own, as a web request in another process would + */ + protected function changeFromAnotherProcess(string $key, mixed $value): void + { + $other = $this->app->build(SettingsRepository::class); + + $other->set($key, $value); + } +} diff --git a/tests/Feature/QueueWorkerProcessTest.php b/tests/Feature/QueueWorkerProcessTest.php new file mode 100644 index 0000000..e9ef195 --- /dev/null +++ b/tests/Feature/QueueWorkerProcessTest.php @@ -0,0 +1,132 @@ +directory = sys_get_temp_dir() . '/javaabu-settings-worker-' . uniqid(); + + (new Filesystem)->ensureDirectoryExists($this->directory); + + $this->database = $this->directory . '/database.sqlite'; + + touch($this->database); + + $app['config']->set('database.default', 'sqlite'); + $app['config']->set('database.connections.sqlite.database', $this->database); + $app['config']->set('cache.default', 'database'); + $app['config']->set('queue.default', 'database'); + $app['config']->set('settings.store', 'database'); + } + + protected function tearDown(): void + { + $this->worker?->stop(0); + + (new Filesystem)->deleteDirectory($this->directory); + + parent::tearDown(); + } + + #[Test] + public function it_gives_a_running_worker_the_settings_changed_by_another_process(): void + { + $this->artisan('migrate')->assertSuccessful(); + + Settings::set('site_name', 'ރާއްޖެ'); + + $this->startWorker(); + + RecordSetting::dispatch('site_name', "{$this->directory}/first.json"); + + $this->assertEquals('ރާއްޖެ', $this->waitForRecording('first')); + + Settings::set('site_name', 'މާލެ'); + + RecordSetting::dispatch('site_name', "{$this->directory}/second.json"); + + $this->assertEquals('މާލެ', $this->waitForRecording('second')); + } + + #[Test] + public function it_leaves_a_running_worker_with_stale_settings_when_refreshing_is_off(): void + { + $this->artisan('migrate')->assertSuccessful(); + + Settings::set('site_name', 'ރާއްޖެ'); + + $this->startWorker(['SETTINGS_REFRESH_QUEUE' => 'false']); + + RecordSetting::dispatch('site_name', "{$this->directory}/first.json"); + + $this->assertEquals('ރާއްޖެ', $this->waitForRecording('first')); + + Settings::set('site_name', 'މާލެ'); + + RecordSetting::dispatch('site_name', "{$this->directory}/second.json"); + + $this->assertEquals('ރާއްޖެ', $this->waitForRecording('second')); + } + + /** + * @param array $env + */ + protected function startWorker(array $env = []): void + { + $this->worker = new Process( + [PHP_BINARY, 'vendor/bin/testbench', 'queue:work', 'database', '--max-jobs=2', '--sleep=0', '--tries=1', '--timeout=30'], + dirname(__DIR__, 2), + [ + 'DB_CONNECTION' => 'sqlite', + 'DB_DATABASE' => $this->database, + 'CACHE_STORE' => 'database', + 'QUEUE_CONNECTION' => 'database', + ...$env, + ], + timeout: 60, + ); + + $this->worker->start(); + } + + protected function waitForRecording(string $name): mixed + { + $path = "{$this->directory}/{$name}.json"; + $deadline = microtime(true) + 30; + + while (! file_exists($path)) { + if (microtime(true) > $deadline || ! $this->worker->isRunning()) { + $this->fail("The worker didn't run the {$name} job.\n" . $this->worker->getOutput() . $this->worker->getErrorOutput()); + } + + usleep(50_000); + } + + return json_decode(file_get_contents($path), true); + } +} diff --git a/tests/Feature/SettingsFormTest.php b/tests/Feature/SettingsFormTest.php new file mode 100644 index 0000000..5848b13 --- /dev/null +++ b/tests/Feature/SettingsFormTest.php @@ -0,0 +1,56 @@ +assertable() + ->assertHasField('site_name') + ->assertState('site_name', settings('site_name')) + ->assertFieldRequired('site_name') + ->assertInvalid(['site_name' => ''], 'site_name'); + } + + #[Test] + public function it_saves_the_settings_of_its_page_from_an_array(): void + { + $values = SettingsForm::forPage(new GeneralSettings)->saveSettings([ + 'site_name' => 'ދިވެހިރާއްޖެ', + 'per_page' => '30', + 'theme' => 'dark', + 'seo_keywords' => [], + ]); + + $this->assertSame(30, $values['per_page']); + $this->assertEquals('ދިވެހިރާއްޖެ', settings('site_name')); + $this->assertEquals('general', SettingsForm::forPage('general')->page()->getKey()); + } + + #[Test] + public function it_fills_file_settings_with_their_url_and_leaves_encrypted_settings_empty(): void + { + Settings::set('mail_password', 'secret'); + + $this->assertEquals(asset('img/logo.png'), SettingsForm::forPage('general')->currentValues()['logo']); + $this->assertNull(SettingsForm::forPage('mail')->currentValues()['mail_password']); + } +} diff --git a/tests/Feature/SettingsRepositoryTest.php b/tests/Feature/SettingsRepositoryTest.php new file mode 100644 index 0000000..1a23888 --- /dev/null +++ b/tests/Feature/SettingsRepositoryTest.php @@ -0,0 +1,207 @@ +assertEquals('ރާއްޖެ', Settings::get('site_name')); + $this->assertSame(20, Settings::get('per_page')); + $this->assertTrue(Settings::get('registration_open')); + $this->assertEquals(['laravel', 'javaabu'], Settings::get('seo_keywords')); + } + + #[Test] + public function it_falls_back_to_the_defaults_config_for_settings_without_a_definition(): void + { + $this->app['config']->set('defaults.app_name', 'Laravel'); + + $this->assertEquals('Laravel', Settings::get('app_name')); + $this->assertEquals('Laravel', Settings::default('app_name')); + } + + #[Test] + public function it_returns_the_given_default_when_a_setting_has_none(): void + { + $this->assertEquals('fallback', Settings::get('unknown', 'fallback')); + $this->assertNull(Settings::get('unknown')); + } + + #[Test] + public function it_stores_and_reads_values(): void + { + Settings::set('site_name', 'މާލެ'); + Settings::set(['per_page' => 15, 'registration_open' => false]); + + $this->assertEquals('މާލެ', Settings::get('site_name')); + $this->assertSame(15, Settings::get('per_page')); + $this->assertFalse(Settings::get('registration_open')); + $this->assertTrue(Settings::has('site_name')); + $this->assertFalse(Settings::has('seo_description')); + } + + #[Test] + public function it_casts_values_by_their_field(): void + { + Settings::store()->put(['per_page' => '30', 'registration_open' => '0']); + + Settings::refresh(); + + $this->assertSame(30, Settings::get('per_page')); + $this->assertFalse(Settings::get('registration_open')); + } + + #[Test] + public function it_casts_enums_and_stores_their_values(): void + { + $this->assertSame(Themes::LIGHT, Settings::get('theme')); + + Settings::set('theme', Themes::DARK); + + $this->assertEquals('dark', Settings::stored()['theme']); + $this->assertSame(Themes::DARK, Settings::get('theme')); + } + + #[Test] + public function it_has_typed_getters(): void + { + $this->assertEquals('ރާއްޖެ', Settings::string('site_name')); + $this->assertSame(20, Settings::integer('per_page')); + $this->assertSame(20.0, Settings::float('per_page')); + $this->assertTrue(Settings::boolean('registration_open')); + $this->assertEquals(['laravel', 'javaabu'], Settings::array('seo_keywords')); + $this->assertEquals('fallback', Settings::string('unknown', 'fallback')); + } + + #[Test] + public function it_throws_when_a_typed_getter_gets_another_type(): void + { + $this->expectException(SettingsException::class); + $this->expectExceptionMessage('Setting [site_name] should be of type [int], [string] given.'); + + Settings::integer('site_name'); + } + + #[Test] + public function it_forgets_values_so_settings_are_back_to_their_defaults(): void + { + Settings::set('site_name', 'މާލެ'); + + Settings::forget('site_name'); + + $this->assertFalse(Settings::has('site_name')); + $this->assertEquals('ރާއްޖެ', Settings::get('site_name')); + } + + #[Test] + public function it_lists_every_setting(): void + { + $this->app['config']->set('defaults', ['app_name' => 'Laravel']); + + Settings::store()->put(['legacy' => 'value']); + Settings::refresh(); + + $all = Settings::all(); + + $this->assertEquals('ރާއްޖެ', $all['site_name']); + $this->assertEquals('value', $all['legacy']); + $this->assertEquals('Laravel', $all['app_name']); + $this->assertArrayHasKey('mail_password', $all); + } + + #[Test] + public function it_keeps_encrypted_settings_encrypted_in_the_store(): void + { + Settings::set('mail_password', 'secret'); + + $stored = Settings::store()->all()['mail_password']; + + $this->assertNotEquals('secret', $stored); + $this->assertEquals('secret', decrypt($stored)); + $this->assertEquals('secret', Settings::get('mail_password')); + } + + #[Test] + public function it_fires_an_event_with_the_old_and_new_values(): void + { + Event::fake([SettingsUpdated::class]); + + Settings::set(['site_name' => 'މާލެ', 'per_page' => 20]); + + Event::assertDispatched(SettingsUpdated::class, function (SettingsUpdated $event) { + return $event->old_settings === ['site_name' => 'ރާއްޖެ', 'per_page' => 20] + && $event->changes() === ['site_name' => 'މާލެ'] + && $event->changed('site_name') + && ! $event->changed('per_page'); + }); + } + + #[Test] + public function it_doesnt_fire_the_event_when_nothing_changed(): void + { + Event::fake([SettingsUpdated::class]); + + Settings::set('per_page', 20); + + Event::assertNotDispatched(SettingsUpdated::class); + } + + #[Test] + public function it_defines_settings_without_a_page(): void + { + Settings::define([ + Setting::make('skeleton_version')->default('9.0.0'), + Setting::make('max_upload_size')->default('2048')->cast('integer'), + ]); + + $this->assertEquals('9.0.0', Settings::get('skeleton_version')); + $this->assertSame(2048, Settings::get('max_upload_size')); + $this->assertArrayNotHasKey('skeleton_version', Settings::pages()); + } + + #[Test] + public function it_evaluates_closure_defaults_each_time(): void + { + $this->app['config']->set('app.name', 'First'); + + Settings::define(Setting::make('app_title')->default(fn () => config('app.name'))); + + $this->assertEquals('First', Settings::get('app_title')); + + $this->app['config']->set('app.name', 'Second'); + + $this->assertEquals('Second', Settings::get('app_title')); + } + + #[Test] + public function it_gives_the_url_of_file_settings(): void + { + $this->assertEquals(asset('img/logo.png'), Settings::url('logo')); + + Settings::set('logo', 'https://cdn.example.com/logo.png'); + + $this->assertEquals('https://cdn.example.com/logo.png', Settings::url('logo')); + $this->assertNull(Settings::url('seo_description')); + } +} diff --git a/tests/Feature/SettingsUpdatedEventTest.php b/tests/Feature/SettingsUpdatedEventTest.php deleted file mode 100644 index d0e2cf6..0000000 --- a/tests/Feature/SettingsUpdatedEventTest.php +++ /dev/null @@ -1,46 +0,0 @@ - 'foo', 'title' => 'bar'], - ['name' => 'bar', 'title' => 'bar'], - )); - - /** @var Activity $log */ - $log = Activity::latest('id')->first(); - $changes = $log->changes(); - - $old = $changes->get('old'); - $new = $changes->get('attributes'); - - $this->assertDatabaseHas('activity_log', [ - 'id' => $log->id, - 'description' => 'settings', - 'subject_type' => null, - 'subject_id' => null, - 'causer_type' => null, - 'causer_id' => null, - ]); - - $this->assertArrayHasKey('name', $new); - $this->assertEquals('bar', $new['name']); - - $this->assertArrayHasKey('name', $old); - $this->assertEquals('foo', $old['name']); - - $this->assertArrayNotHasKey('title', $new); - $this->assertArrayNotHasKey('title', $old); - } -} diff --git a/tests/Feature/StoresTest.php b/tests/Feature/StoresTest.php new file mode 100644 index 0000000..e866a82 --- /dev/null +++ b/tests/Feature/StoresTest.php @@ -0,0 +1,191 @@ +json_path = sys_get_temp_dir() . '/javaabu-settings-' . uniqid() . '/settings.json'; + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory(dirname($this->json_path)); + + parent::tearDown(); + } + + #[Test] + public function it_keeps_values_in_the_database(): void + { + $this->app['config']->set('settings.store', 'database'); + + $store = Settings::store(); + + $this->assertInstanceOf(DatabaseStore::class, $store); + + $this->assertStoreWorks($store); + + $store->put(['site_name' => 'ރާއްޖެ']); + + $this->assertDatabaseHas('settings', ['key' => 'site_name', 'value' => '"ރާއްޖެ"']); + } + + #[Test] + public function it_keeps_values_in_a_json_file(): void + { + $this->app['config']->set('settings.store', 'json'); + $this->app['config']->set('settings.stores.json.path', $this->json_path); + + $store = Settings::store(); + + $this->assertInstanceOf(JsonStore::class, $store); + $this->assertEquals([], $store->all()); + + $this->assertStoreWorks($store); + + $store->put(['site_name' => 'ރާއްޖެ']); + + $this->assertStringContainsString('"site_name": "ރާއްޖެ"', file_get_contents($this->json_path)); + } + + #[Test] + public function it_rejects_a_json_file_that_isnt_an_object(): void + { + (new Filesystem)->ensureDirectoryExists(dirname($this->json_path)); + file_put_contents($this->json_path, '"text"'); + + $this->expectException(SettingsException::class); + + (new JsonStore(new Filesystem, $this->json_path))->all(); + } + + #[Test] + public function it_keeps_values_in_memory(): void + { + $this->assertStoreWorks(new ArrayStore); + } + + #[Test] + public function it_adds_store_drivers(): void + { + $this->app['config']->set('settings.store', 'custom'); + $this->app['config']->set('settings.stores.custom', ['values' => ['site_name' => 'Custom']]); + + Settings::extend('custom', fn ($app, array $config) => new ArrayStore($config['values'])); + + $this->assertEquals('Custom', Settings::get('site_name')); + } + + #[Test] + public function it_reads_the_store_once_and_caches_the_values(): void + { + $this->app['config']->set('settings.store', 'database'); + + Settings::page(GeneralSettings::class); + Settings::set('site_name', 'މާލެ'); + + $this->app['cache']->store()->flush(); + + $queries = 0; + $this->app['db']->listen(function () use (&$queries) { + $queries++; + }); + + $other = $this->newRepository(); + + $this->assertEquals('މާލެ', $other->get('site_name')); + $this->assertEquals(20, $other->get('per_page')); + $this->assertEquals(1, $queries); + + $third = $this->newRepository(); + + $this->assertEquals('މާލެ', $third->get('site_name')); + $this->assertEquals(1, $queries); + } + + #[Test] + public function it_reads_the_store_every_time_without_the_cache(): void + { + $this->app['config']->set('settings.store', 'database'); + $this->app['config']->set('settings.cache.enabled', false); + + Settings::set('site_name', 'މާލެ'); + + $queries = 0; + $this->app['db']->listen(function () use (&$queries) { + $queries++; + }); + + $this->newRepository()->get('site_name'); + $this->newRepository()->get('site_name'); + + $this->assertEquals(2, $queries); + $this->assertNull(Settings::version()); + } + + #[Test] + public function it_flushes_every_value(): void + { + Settings::set(['site_name' => 'މާލެ', 'other' => 1]); + + Settings::flush(); + + $this->assertEquals([], Settings::stored()); + } + + protected function assertStoreWorks(SettingsStore $store): void + { + $store->put(['site_name' => 'ރާއްޖެ', 'per_page' => 20, 'keywords' => ['a', 'b'], 'open' => true, 'empty' => null]); + + $this->assertSame(['site_name' => 'ރާއްޖެ', 'per_page' => 20, 'keywords' => ['a', 'b'], 'open' => true, 'empty' => null], $this->sorted($store->all(), ['site_name', 'per_page', 'keywords', 'open', 'empty'])); + + $store->put(['per_page' => 30]); + $this->assertSame(30, $store->all()['per_page']); + + $store->forget(['keywords', 'empty']); + $this->assertArrayNotHasKey('keywords', $store->all()); + $this->assertArrayNotHasKey('empty', $store->all()); + + $store->put([]); + $store->forget([]); + + $store->flush(); + $this->assertEquals([], $store->all()); + } + + /** + * @param array $values + * @param array $keys + * @return array + */ + protected function sorted(array $values, array $keys): array + { + return array_merge(array_flip($keys), $values); + } + + protected function newRepository(): SettingsRepository + { + return $this->app->build(SettingsRepository::class); + } +} diff --git a/tests/Http/AuthorizationTest.php b/tests/Http/AuthorizationTest.php new file mode 100644 index 0000000..94ebebe --- /dev/null +++ b/tests/Http/AuthorizationTest.php @@ -0,0 +1,171 @@ +setUpSettings(); + } + + #[Test] + public function it_registers_the_permissions_and_the_permissions_of_pages(): void + { + $this->assertTrue(Permissions::has('view_settings')); + $this->assertTrue(Permissions::has('edit_settings')); + $this->assertTrue(Permissions::has('edit_mail_settings')); + $this->assertEquals('Settings', Permissions::label('settings')); + $this->assertDatabaseHas('permissions', ['name' => 'edit_settings', 'guard_name' => 'web_admin']); + $this->assertDatabaseHas('permissions', ['name' => 'edit_mail_settings', 'guard_name' => 'web']); + } + + #[Test] + public function it_rejects_guests(): void + { + $this->getJson('/settings')->assertUnauthorized(); + $this->getJson('/settings/general')->assertUnauthorized(); + $this->patchJson('/settings/general', $this->validInput())->assertUnauthorized(); + } + + #[Test] + public function it_forbids_users_without_a_settings_permission(): void + { + $user = $this->user([]); + + $this->actingAs($user)->get('/settings')->assertForbidden(); + $this->actingAs($user)->get('/settings/general')->assertForbidden(); + $this->actingAs($user)->patch('/settings/general', $this->validInput())->assertForbidden(); + + $this->assertFalse($this->freshSettings()->has('site_name')); + } + + #[Test] + public function it_lets_users_who_can_view_see_a_disabled_form_but_not_save(): void + { + $user = $this->user(['view_settings']); + + $this->actingAs($user) + ->get('/settings/general') + ->assertOk() + ->assertSee('You can view these settings but not change them.') + ->assertDontSee('Save settings') + ->assertSee('disabled', false); + + $this->actingAs($user)->patch('/settings/general', $this->validInput())->assertForbidden(); + } + + #[Test] + public function it_needs_the_permission_of_a_page_that_has_its_own(): void + { + $editor = $this->user(['edit_settings']); + + $this->actingAs($editor)->get('/settings/mail')->assertForbidden(); + $this->actingAs($editor)->patch('/settings/mail', ['mail_from_address' => 'a@example.com'])->assertForbidden(); + + $mailer = $this->user(['edit_mail_settings']); + + $this->actingAs($mailer)->get('/settings/general')->assertForbidden(); + $this->actingAs($mailer) + ->patch('/settings/mail', ['mail_from_address' => 'info@javaabu.com']) + ->assertRedirect('/settings/mail'); + + $this->assertEquals('info@javaabu.com', $this->freshSettings()->get('mail_from_address')); + } + + #[Test] + public function it_lists_only_the_pages_a_user_can_see(): void + { + $this->actingAs($this->user(['edit_mail_settings'])) + ->getJson('/settings') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.key', 'mail'); + } + + #[Test] + public function it_works_for_another_guard_and_user_model(): void + { + $admin = $this->admin(['edit_settings']); + + $this->actingAs($admin, 'web_admin') + ->get('/admin/settings') + ->assertRedirect('/admin/settings/general'); + + $this->actingAs($admin, 'web_admin') + ->get('/admin/settings/general') + ->assertOk() + ->assertSee('/admin/settings/general', false); + + $this->actingAs($admin, 'web_admin') + ->patch('/admin/settings/general', $this->validInput()) + ->assertRedirect('/admin/settings/general'); + + $this->assertEquals('ދިވެހިރާއްޖެ', $this->freshSettings()->get('site_name')); + } + + #[Test] + public function it_doesnt_let_a_user_of_one_guard_into_the_routes_of_another(): void + { + $this->actingAs($this->user(['edit_settings'])) + ->getJson('/admin/settings/general') + ->assertUnauthorized(); + } + + #[Test] + public function it_doesnt_let_an_admin_into_the_routes_of_users(): void + { + $this->actingAs($this->admin(['edit_settings']), 'web_admin'); + + Auth::shouldUse('web'); + + $this->getJson('/settings/general')->assertUnauthorized(); + } + + #[Test] + public function the_app_can_override_the_policy(): void + { + Gate::policy(SettingsPage::class, DenyAllPolicy::class); + + $this->actingAs($this->user(['edit_settings']))->get('/settings/general')->assertForbidden(); + } + + #[Test] + public function pages_can_hide_themselves(): void + { + Settings::page('hidden')->schema([])->permission('edit_settings'); + + $page = new class extends SettingsPage + { + protected ?string $key = 'secret'; + + public function definition(): array + { + return []; + } + + public function isVisible(): bool + { + return false; + } + }; + + Settings::page($page); + + $this->actingAs($this->user(['edit_settings']))->get('/settings/secret')->assertNotFound(); + $this->actingAs($this->user(['edit_settings']))->get('/settings/hidden')->assertOk(); + } +} diff --git a/tests/Http/InertiaPagesTest.php b/tests/Http/InertiaPagesTest.php new file mode 100644 index 0000000..0a254d6 --- /dev/null +++ b/tests/Http/InertiaPagesTest.php @@ -0,0 +1,113 @@ +defineSettingsEnvironment($app); + + $app['config']->set('settings.views', 'inertia'); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpSettings(); + } + + #[Test] + public function it_renders_the_inertia_page_with_the_stencil_schema(): void + { + Settings::set('site_name', 'ދިވެހިރާއްޖެ'); + + $response = $this->actingAs($this->user(['edit_settings', 'edit_mail_settings'])) + ->get('/settings/general') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('settings::Settings/Edit') + ->where('page.key', 'general') + ->where('page.label', 'General') + ->where('page.can_update', true) + ->where('pages.0.active', true) + ->where('pages.1.key', 'mail') + ->where('form.id', 'settings-general') + ->where('form.method', 'PATCH') + ->where('form.state.site_name', 'ދިވެހިރާއްޖެ') + ->where('form.state.per_page', 20) + ->where('form.components.0.name', 'site_name') + ->where('strings.title', 'Settings') + ->where('status', null) + ->etc()); + + if (getenv('UPDATE_FIXTURES')) { + file_put_contents( + __DIR__ . '/../js/fixtures/edit-props.json', + json_encode($response->viewData('page')['props'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", + ); + } + } + + #[Test] + public function it_sends_the_status_after_saving(): void + { + $user = $this->user(); + + $this->actingAs($user) + ->patch('/settings/general', $this->validInput()) + ->assertRedirect('/settings/general'); + + $this->actingAs($user) + ->get('/settings/general') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('status', 'Settings saved.') + ->where('form.state.site_name', 'ދިވެހިރާއްޖެ') + ->etc()); + } + + #[Test] + public function it_disables_the_form_for_users_who_can_only_view(): void + { + $this->actingAs($this->user(['view_settings'])) + ->get('/settings/general') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('page.can_update', false) + ->where('form.components.0.props.disabled', true) + ->missing('form.submit') + ->etc()); + } + + #[Test] + public function it_translates_the_page_into_dhivehi(): void + { + $this->app->setLocale('dv'); + + $this->actingAs($this->user()) + ->get('/settings/general') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('strings.title', 'ސެޓިންގްސް') + ->where('strings.read_only', 'ތިބާއަށް މި ސެޓިންގްސް ބެލޭނެ، ނަމަވެސް ބަދަލެއް ނުކުރެވޭނެ.') + ->where('form.submit', 'ސެޓިންގްސް ސޭވްކުރޭ') + ->etc()); + } +} diff --git a/tests/Http/InteractsWithSettings.php b/tests/Http/InteractsWithSettings.php new file mode 100644 index 0000000..113bdd6 --- /dev/null +++ b/tests/Http/InteractsWithSettings.php @@ -0,0 +1,89 @@ +set('settings.store', 'database'); + $app['config']->set('settings.pages', [GeneralSettings::class, MailSettings::class]); + } + + protected function defineRoutes($router): void + { + $router->middleware('web')->group(function ($router) { + $router->settings(); + $router->settings(['guard' => 'web_admin', 'prefix' => 'admin/settings', 'name' => 'admin.settings']); + }); + } + + protected function setUpSettings(): void + { + $this->artisan('permissions:sync'); + } + + /** + * @param array $permissions + */ + protected function user(array $permissions = ['edit_settings']): User + { + $user = User::create([ + 'name' => 'އަޙްމަދު', + 'email' => uniqid() . '@example.com', + 'password' => 'secret', + ]); + + $user->givePermissionTo($permissions); + + return $user; + } + + /** + * @param array $permissions + */ + protected function admin(array $permissions = ['edit_settings']): Admin + { + $admin = Admin::create([ + 'name' => 'Aisha', + 'email' => uniqid() . '@example.com', + 'password' => 'secret', + ]); + + $admin->givePermissionTo($permissions); + + return $admin; + } + + /** + * @return array + */ + protected function validInput(array $overrides = []): array + { + return array_merge([ + 'site_name' => 'ދިވެހިރާއްޖެ', + 'per_page' => '25', + 'registration_open' => '1', + 'theme' => 'dark', + 'seo_description' => 'ރާއްޖޭގެ ވެބްސައިޓް', + 'seo_keywords' => ['dhivehi'], + ], $overrides); + } + + protected function freshSettings(): SettingsRepository + { + return Settings::getFacadeRoot()->refresh(); + } +} diff --git a/tests/Http/LiveFieldsTest.php b/tests/Http/LiveFieldsTest.php new file mode 100644 index 0000000..e015663 --- /dev/null +++ b/tests/Http/LiveFieldsTest.php @@ -0,0 +1,95 @@ +middleware('web')->group(function ($router) { + $router->settings(); + $router->stencil()->middleware('auth'); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + Settings::page('storage')->schema(fn () => [ + Setting::make('storage_mode') + ->default('local') + ->field(Select::class) + ->options(['local' => 'Local', 'remote' => 'Remote']) + ->live(), + Setting::make('storage_url') + ->visible(fn (Get $get) => $get('storage_mode') === 'remote') + ->required(), + ]); + + $this->setUpSettings(); + } + + #[Test] + public function it_re_evaluates_settings_forms_on_the_live_route(): void + { + $user = $this->user(); + + $this->actingAs($user); + + $schema = SettingsForm::forPage('storage')->render('json'); + + $this->assertNotNull($schema['live']['token']); + $this->assertTrue($schema['components'][1]['hidden'] ?? false); + + $this->postJson(route('stencil.evaluate'), [ + '_stencil' => $schema['live']['token'], + '_stencil_changed' => 'storage_mode', + 'storage_mode' => 'remote', + ]) + ->assertOk() + ->assertJsonPath('state.storage_mode', 'remote') + ->assertJsonPath('schema.components.1.name', 'storage_url') + ->assertJsonMissingPath('schema.components.1.hidden'); + } + + #[Test] + public function it_validates_fields_shown_by_closures_only_when_they_are_visible(): void + { + $user = $this->user(); + + $this->actingAs($user) + ->patch('/settings/storage', ['storage_mode' => 'local']) + ->assertSessionHasNoErrors(); + + $this->actingAs($user) + ->patch('/settings/storage', ['storage_mode' => 'remote']) + ->assertSessionHasErrors('storage_url'); + } + + #[Test] + public function it_refuses_to_re_evaluate_for_users_who_cant_edit_the_page(): void + { + $user = $this->user(['view_settings']); + + $this->actingAs($user); + + $token = SettingsForm::forPage('storage')->render('json')['live']['token']; + + $this->postJson(route('stencil.evaluate'), [ + '_stencil' => $token, + '_stencil_changed' => 'storage_mode', + 'storage_mode' => 'remote', + ])->assertForbidden(); + } +} diff --git a/tests/Http/OverridingControllersTest.php b/tests/Http/OverridingControllersTest.php new file mode 100644 index 0000000..fca563a --- /dev/null +++ b/tests/Http/OverridingControllersTest.php @@ -0,0 +1,60 @@ +defineSettingsEnvironment($app); + + $app['config']->set('settings.controllers.settings', CustomSettingsController::class); + } + + protected function defineRoutes($router): void + { + $router->middleware('web')->group(function ($router) { + $router->settings(); + $router->settings(['prefix' => 'other', 'name' => 'other', 'controller' => CustomSettingsController::class, 'middleware' => ['auth']]); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + CustomSettingsController::$saved = []; + + $this->setUpSettings(); + } + + #[Test] + public function it_uses_the_controller_from_config(): void + { + $this->actingAs($this->user()) + ->patch('/settings/general', $this->validInput()) + ->assertRedirect('/settings/general'); + + $this->assertCount(1, CustomSettingsController::$saved); + $this->assertEquals('ދިވެހިރާއްޖެ', CustomSettingsController::$saved[0]['site_name']); + } + + #[Test] + public function it_uses_the_controller_passed_to_the_macro_with_its_own_prefix_and_names(): void + { + $this->actingAs($this->user()) + ->patch('/other/general', $this->validInput()) + ->assertRedirect('/other/general'); + + $this->assertCount(1, CustomSettingsController::$saved); + $this->assertEquals(url('/other/general'), route('other.edit', 'general')); + } +} diff --git a/tests/Http/SettingsControllerTest.php b/tests/Http/SettingsControllerTest.php new file mode 100644 index 0000000..ab7246a --- /dev/null +++ b/tests/Http/SettingsControllerTest.php @@ -0,0 +1,232 @@ +setUpSettings(); + } + + #[Test] + public function it_redirects_the_index_to_the_first_page(): void + { + $this->actingAs($this->user()) + ->get('/settings') + ->assertRedirect('/settings/general'); + } + + #[Test] + public function it_redirects_to_the_first_page_the_user_can_see(): void + { + $this->actingAs($this->user(['edit_mail_settings'])) + ->get('/settings') + ->assertRedirect('/settings/mail'); + } + + #[Test] + public function it_shows_the_form_of_a_page_with_the_current_values(): void + { + Settings::set('site_name', 'ދިވެހިރާއްޖެ'); + + $this->actingAs($this->user(['edit_settings', 'edit_mail_settings'])) + ->get('/settings/general') + ->assertOk() + ->assertSee('data-settings-page="general"', false) + ->assertSee('ދިވެހިރާއްޖެ') + ->assertSee('Site name') + ->assertSee('Search engines') + ->assertSee('/settings/mail') + ->assertSee('Save settings') + ->assertSee('name="per_page"', false) + ->assertSee('value="20"', false); + } + + #[Test] + public function it_saves_a_page(): void + { + $this->actingAs($this->user()) + ->patch('/settings/general', $this->validInput()) + ->assertSessionHasNoErrors() + ->assertRedirect('/settings/general') + ->assertSessionHas('status', 'Settings saved.'); + + $this->app->setLocale('dv'); + + $this->actingAs($this->user()) + ->patch('/settings/general', $this->validInput()) + ->assertSessionHas('status', 'ސެޓިންގްސް ސޭވްކުރެވިއްޖެ.'); + + $settings = $this->freshSettings(); + + $this->assertEquals('ދިވެހިރާއްޖެ', $settings->get('site_name')); + $this->assertSame(25, $settings->get('per_page')); + $this->assertTrue($settings->get('registration_open')); + $this->assertSame(Themes::DARK, $settings->get('theme')); + $this->assertEquals('ރާއްޖޭގެ ވެބްސައިޓް', $settings->get('seo_description')); + $this->assertEquals(['dhivehi'], $settings->get('seo_keywords')); + + $this->assertDatabaseHas('settings', ['key' => 'site_name', 'value' => '"ދިވެހިރާއްޖެ"']); + } + + #[Test] + public function it_saves_an_unchecked_checkbox_as_false(): void + { + $input = $this->validInput(); + unset($input['registration_open']); + + $this->actingAs($this->user())->patch('/settings/general', $input)->assertSessionHasNoErrors(); + + $this->assertFalse($this->freshSettings()->get('registration_open')); + } + + #[Test] + public function it_validates_with_the_rules_of_the_fields(): void + { + $this->actingAs($this->user()) + ->from('/settings/general') + ->patch('/settings/general', $this->validInput([ + 'site_name' => '', + 'per_page' => '500', + 'theme' => 'purple', + 'seo_keywords' => ['php'], + ])) + ->assertRedirect('/settings/general') + ->assertSessionHasErrors(['site_name', 'per_page', 'theme', 'seo_keywords.0']); + + $this->assertFalse($this->freshSettings()->has('site_name')); + } + + #[Test] + public function it_ignores_input_that_isnt_on_the_page(): void + { + $this->actingAs($this->user()) + ->patch('/settings/general', $this->validInput(['mail_from_address' => 'hacker@example.com', 'other' => 'x'])) + ->assertSessionHasNoErrors(); + + $this->assertFalse($this->freshSettings()->has('mail_from_address')); + $this->assertFalse($this->freshSettings()->has('other')); + } + + #[Test] + public function it_uploads_file_settings_and_deletes_the_file_they_replace(): void + { + Storage::fake('public'); + + $user = $this->user(); + + $this->actingAs($user) + ->patch('/settings/general', $this->validInput(['logo' => UploadedFile::fake()->image('logo.png')])) + ->assertSessionHasNoErrors(); + + $first = $this->freshSettings()->get('logo'); + + $this->assertStringStartsWith('settings/', $first); + Storage::disk('public')->assertExists($first); + $this->assertEquals(Storage::disk('public')->url($first), $this->freshSettings()->url('logo')); + + $this->actingAs($user) + ->patch('/settings/general', $this->validInput(['logo' => UploadedFile::fake()->image('new-logo.png')])) + ->assertSessionHasNoErrors(); + + $second = $this->freshSettings()->get('logo'); + + $this->assertNotEquals($first, $second); + Storage::disk('public')->assertMissing($first); + Storage::disk('public')->assertExists($second); + + $this->actingAs($user)->patch('/settings/general', $this->validInput())->assertSessionHasNoErrors(); + + $this->assertEquals($second, $this->freshSettings()->get('logo')); + } + + #[Test] + public function it_never_sends_encrypted_values_and_keeps_them_when_left_empty(): void + { + Settings::set('mail_password', 'super-secret'); + + $user = $this->user(['edit_mail_settings']); + + $this->actingAs($user) + ->get('/settings/mail') + ->assertOk() + ->assertDontSee('super-secret') + ->assertSee('Leave blank to keep the current value.'); + + $this->actingAs($user) + ->patch('/settings/mail', ['mail_from_address' => 'info@javaabu.com', 'mail_password' => '']) + ->assertSessionHasNoErrors(); + + $this->assertEquals('super-secret', $this->freshSettings()->get('mail_password')); + $this->assertEquals('info@javaabu.com', $this->freshSettings()->get('mail_from_address')); + + $this->actingAs($user) + ->patch('/settings/mail', ['mail_from_address' => 'info@javaabu.com', 'mail_password' => 'new-secret']) + ->assertSessionHasNoErrors(); + + $this->assertEquals('new-secret', $this->freshSettings()->get('mail_password')); + } + + #[Test] + public function it_returns_404_for_an_unknown_page(): void + { + $this->actingAs($this->user())->get('/settings/missing')->assertNotFound(); + $this->actingAs($this->user())->patch('/settings/missing')->assertNotFound(); + } + + #[Test] + public function it_shows_the_pages_in_the_users_locale(): void + { + $this->app->setLocale('dv'); + + $this->actingAs($this->user()) + ->get('/settings/general') + ->assertOk() + ->assertSee('dir="rtl"', false) + ->assertSee('

ސެޓިންގްސް

', false) + ->assertSee('ސެޓިންގްސް ސޭވްކުރޭ'); + } + + #[Test] + public function it_returns_json_to_json_requests(): void + { + $user = $this->user(['edit_settings']); + + $this->actingAs($user) + ->getJson('/settings') + ->assertOk() + ->assertJsonPath('data.0.key', 'general') + ->assertJsonPath('data.0.can_update', true) + ->assertJsonCount(1, 'data'); + + $this->actingAs($user) + ->getJson('/settings/general') + ->assertOk() + ->assertJsonPath('page.label', 'General') + ->assertJsonPath('values.site_name', 'ރާއްޖެ') + ->assertJsonPath('values.per_page', 20); + + $this->actingAs($user) + ->patchJson('/settings/general', $this->validInput()) + ->assertOk() + ->assertJsonPath('message', 'Settings saved.') + ->assertJsonPath('values.site_name', 'ދިވެހިރާއްޖެ'); + + $this->actingAs($user) + ->patchJson('/settings/general', $this->validInput(['site_name' => ''])) + ->assertUnprocessable() + ->assertJsonValidationErrors('site_name'); + } +} diff --git a/tests/Http/TranslatableSettingsTest.php b/tests/Http/TranslatableSettingsTest.php new file mode 100644 index 0000000..4e3ec81 --- /dev/null +++ b/tests/Http/TranslatableSettingsTest.php @@ -0,0 +1,192 @@ +defineSettingsEnvironment($app); + + $app['config']->set('settings.pages', [SiteSettings::class]); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setUpSettings(); + $this->setUpLanguages(['en', 'dv']); + } + + #[Test] + public function it_renders_an_input_per_language(): void + { + Settings::set('site_title', ['en' => 'Maldives', 'dv' => 'ދިވެހިރާއްޖެ']); + + $this->actingAs($this->user()) + ->get('/settings/site') + ->assertOk() + ->assertSee('name="site_title_en"', false) + ->assertSee('name="site_title_dv"', false) + ->assertSee('value="ދިވެހިރާއްޖެ"', false) + ->assertSee('name="footer_text_dv"', false) + ->assertSee('name="contact_email"', false) + ->assertDontSee('name="contact_email_dv"', false); + } + + #[Test] + public function it_saves_a_value_per_language_and_reads_the_current_one(): void + { + $this->actingAs($this->user()) + ->patch('/settings/site', [ + 'site_title_en' => 'Maldives', + 'site_title_dv' => 'ދިވެހިރާއްޖެ', + 'footer_text_en' => 'Made in Malé', + 'footer_text_dv' => '', + 'contact_email' => 'hello@example.com', + ]) + ->assertSessionHasNoErrors() + ->assertRedirect('/settings/site'); + + $settings = $this->freshSettings(); + + $this->assertEquals(['en' => 'Maldives', 'dv' => 'ދިވެހިރާއްޖެ'], $settings->stored()['site_title']); + $this->assertEquals('Maldives', $settings->get('site_title')); + $this->assertEquals('ދިވެހިރާއްޖެ', $settings->translate('site_title', 'dv')); + $this->assertEquals('hello@example.com', $settings->get('contact_email')); + + Languages::setCurrentLocale('dv'); + + $this->assertEquals('ދިވެހިރާއްޖެ', settings('site_title')); + $this->assertEquals('Made in Malé', settings('footer_text'), 'falls back to the default language'); + $this->assertNull($settings->translate('footer_text', 'dv', fallback: false)); + } + + #[Test] + public function it_requires_only_the_default_language(): void + { + $user = $this->user(); + + $this->actingAs($user) + ->patch('/settings/site', ['site_title_en' => '', 'site_title_dv' => 'ދިވެހިރާއްޖެ']) + ->assertSessionHasErrors('site_title_en') + ->assertSessionDoesntHaveErrors('site_title_dv'); + + $this->actingAs($user) + ->patch('/settings/site', ['site_title_en' => 'Maldives', 'site_title_dv' => '']) + ->assertSessionHasNoErrors(); + } + + #[Test] + public function it_can_require_every_language(): void + { + Settings::page('strict')->schema([ + Setting::make('motto')->required()->translatable(require_all: true), + ]); + + $this->actingAs($this->user()) + ->patch('/settings/strict', ['motto_en' => 'Hello', 'motto_dv' => '']) + ->assertSessionHasErrors('motto_dv'); + } + + #[Test] + public function it_edits_a_record_that_holds_every_language(): void + { + Settings::set('site_title', ['en' => 'Maldives', 'dv' => 'ދިވެހިރާއްޖެ']); + + $form = SettingsForm::forPage('site'); + + $this->assertInstanceOf(SettingsRecord::class, $form->getRecord()); + $this->assertEquals(['site_title', 'footer_text'], $form->getRecord()->getTranslatables()); + + $form->assertable() + ->assertState('site_title_en', 'Maldives') + ->assertState('site_title_dv', 'ދިވެހިރާއްޖެ'); + + $this->assertEquals('ދިވެހިރާއްޖެ', $form->currentValues()['site_title_dv']); + } + + #[Test] + public function it_sends_the_inputs_to_the_react_page_and_json_clients(): void + { + $this->app['config']->set('settings.views', 'inertia'); + + $user = $this->user(); + + $this->actingAs($user) + ->get('/settings/site') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('form.components.0.translatable.inputs.0.name', 'site_title_en') + ->where('form.components.0.translatable.inputs.1.name', 'site_title_dv') + ->etc()); + + $this->actingAs($user) + ->getJson('/settings/site') + ->assertJsonPath('values.site_title', 'Maldives') + ->assertJsonPath('values.site_title_en', 'Maldives') + ->assertJsonPath('values.site_title_dv', null); + } + + #[Test] + public function it_is_a_plain_setting_when_translations_are_off(): void + { + $this->withoutTranslatable(function () { + $this->assertFalse(Translations::enabled()); + + $this->actingAs($this->user()) + ->get('/settings/site') + ->assertSee('name="site_title"', false) + ->assertDontSee('name="site_title_dv"', false); + + $this->actingAs($this->user()) + ->patch('/settings/site', ['site_title' => 'Maldives']) + ->assertSessionHasNoErrors(); + + $this->assertEquals('Maldives', $this->freshSettings()->stored()['site_title']); + }); + } + + #[Test] + public function it_reads_stored_translations_after_translations_are_turned_off(): void + { + Settings::set('site_title', ['en' => 'Maldives', 'dv' => 'ދިވެހިރާއްޖެ']); + + $this->withoutTranslatable(function () { + $this->app->setLocale('dv'); + + $this->assertEquals('ދިވެހިރާއްޖެ', $this->freshSettings()->get('site_title')); + + $this->app->setLocale('fr'); + + $this->assertEquals('Maldives', $this->freshSettings()->get('site_title')); + }); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 8b3f13a..243d709 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,78 +2,77 @@ namespace Javaabu\Settings\Tests; -use Illuminate\Support\Facades\Artisan; +use Javaabu\Forms\FormsServiceProvider; +use Javaabu\Manifest\ManifestServiceProvider; +use Javaabu\Permissions\Models\Permission; +use Javaabu\Permissions\Models\Role; +use Javaabu\Permissions\PermissionsServiceProvider; +use Javaabu\Settings\Facades\Settings; +use Javaabu\Settings\SettingsRegistry; use Javaabu\Settings\SettingsServiceProvider; +use Javaabu\Settings\Tests\TestSupport\Models\Admin; +use Javaabu\Settings\Tests\TestSupport\Models\User; +use Javaabu\Settings\Tests\TestSupport\Providers\TestServiceProvider; +use Javaabu\Stencil\StencilServiceProvider; +use Javaabu\Translatable\TranslatableServiceProvider; use Orchestra\Testbench\TestCase as BaseTestCase; use Spatie\Activitylog\ActivitylogServiceProvider; +use Spatie\Permission\PermissionServiceProvider; abstract class TestCase extends BaseTestCase { - public function setUp(): void + protected function defineEnvironment($app): void { - parent::setUp(); - - $this->app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); - - $this->app['config']->set('session.serialization', 'php'); - - $this->setupDefaultSettings(); + $app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $app['config']->set('permission.models.permission', Permission::class); + $app['config']->set('permission.models.role', Role::class); + $app['config']->set('settings.store', 'array'); + $app['config']->set('settings.log_activity', false); + $app['config']->set('permissions.guards', ['web', 'web_admin']); + + $app['config']->set('auth.guards', [ + 'web' => ['driver' => 'session', 'provider' => 'users'], + 'web_admin' => ['driver' => 'session', 'provider' => 'admins'], + ]); - if (empty(glob($this->app->databasePath('migrations/*_create_activity_log_table.php')))) { - Artisan::call('vendor:publish', [ - '--provider' => 'Spatie\\Activitylog\\ActivitylogServiceProvider', - '--tag' => 'activitylog-migrations', - ]); - } + $app['config']->set('auth.providers', [ + 'users' => ['driver' => 'eloquent', 'model' => User::class], + 'admins' => ['driver' => 'eloquent', 'model' => Admin::class], + ]); } protected function getPackageProviders($app): array { - return [ - \anlutro\LaravelSettings\ServiceProvider::class, + $providers = [ + PermissionServiceProvider::class, + PermissionsServiceProvider::class, + ManifestServiceProvider::class, + FormsServiceProvider::class, + StencilServiceProvider::class, SettingsServiceProvider::class, - ActivitylogServiceProvider::class + TestServiceProvider::class, ]; - } - - protected function setupDefaultSettings() - { - $this->app['config']->set('defaults', [ - /** - * Defaults - */ - 'skeleton_version' => '9.0.0', - - 'app_name' => env('APP_NAME', 'Laravel'), - 'favicon' => 'favicon.ico', - 'apple_touch_icon' => 'img/apple-touch-icon.png', - 'app_logo' => 'img/logo.png', - 'og_image' => 'img/og-image.png', - - 'admin_header_logo' => 'img/admin-header-logo.png', - 'default_avatar' => 'img/user.png', - - 'error_logo' => 'img/error-logo.png', - - 'default_lat' => env('DEFAULT_LAT', 4.175804), - 'default_lng' => env('DEFAULT_LNG', 73.509337), - 'map_marker' => 'img/map-marker.png', + if (class_exists(ActivitylogServiceProvider::class)) { + $providers[] = ActivitylogServiceProvider::class; + } - 'max_upload_file_size' => 1024 * 2, //2MB - 'max_image_file_size' => 1024 * 2, //2MB - 'max_num_files' => 5, + if (class_exists(TranslatableServiceProvider::class)) { + $providers[] = TranslatableServiceProvider::class; + } - 'default_role' => 'guest', + return $providers; + } - 'facebook' => 'javaabu', - 'twitter' => 'javaabumv', + protected function tearDown(): void + { + Settings::clearResolvedInstances(); - 'seo_description' => 'This is a Laravel Skeleton app.', - 'seo_keywords' => 'Code,HTML,CSS,Javascript,PHP,Laravel', - 'google_analytics_id' => '', + parent::tearDown(); + } - 'per_page' => 20, - ]); + protected function registry(): SettingsRegistry + { + return $this->app->make(SettingsRegistry::class); } } diff --git a/tests/TestSupport/Enums/Themes.php b/tests/TestSupport/Enums/Themes.php new file mode 100644 index 0000000..7fffeb3 --- /dev/null +++ b/tests/TestSupport/Enums/Themes.php @@ -0,0 +1,9 @@ +> */ + public static array $saved = []; + + protected function updated(Request $request, SettingsPage $page, array $values): void + { + static::$saved[] = $values; + } +} diff --git a/tests/TestSupport/Jobs/RecordSetting.php b/tests/TestSupport/Jobs/RecordSetting.php new file mode 100644 index 0000000..891836d --- /dev/null +++ b/tests/TestSupport/Jobs/RecordSetting.php @@ -0,0 +1,38 @@ + values recorded by in-process workers */ + public static array $recorded = []; + + public function __construct( + public string $key, + public ?string $path = null, + ) {} + + public function handle(): void + { + $value = settings($this->key); + + static::$recorded[] = $value; + + if ($this->path !== null) { + file_put_contents($this->path, json_encode($value, JSON_UNESCAPED_UNICODE)); + } + } +} diff --git a/tests/TestSupport/Models/Admin.php b/tests/TestSupport/Models/Admin.php new file mode 100644 index 0000000..815a2f1 --- /dev/null +++ b/tests/TestSupport/Models/Admin.php @@ -0,0 +1,15 @@ +default('ރާއްޖެ') + ->label(__('Site name')) + ->required() + ->maxLength(255) + ->thaana(), + Setting::make('per_page') + ->default(20) + ->field(TextInput::make('per_page')->integer()) + ->label(__('Items per page')) + ->required() + ->minValue(1) + ->maxValue(100), + Setting::make('registration_open') + ->default(true) + ->field(Checkbox::class) + ->label(__('Registration is open')), + Setting::make('theme') + ->default(Themes::LIGHT) + ->cast(Themes::class) + ->field(Select::class) + ->options(['light' => __('Light'), 'dark' => __('Dark')]) + ->required(), + Section::make(__('Search engines')) + ->description(__('How the site appears in search results')) + ->columns(2) + ->schema([ + Setting::make('seo_description') + ->field(Textarea::class) + ->label(__('Description')) + ->thaana(), + Setting::make('seo_keywords') + ->default(['laravel', 'javaabu']) + ->field(CheckboxList::class) + ->options(['laravel' => 'Laravel', 'javaabu' => 'Javaabu', 'dhivehi' => 'ދިވެހި']), + ]), + Setting::make('logo') + ->default('img/logo.png') + ->field(ImageUpload::class) + ->label(__('Logo')), + ]; + } +} diff --git a/tests/TestSupport/Pages/MailSettings.php b/tests/TestSupport/Pages/MailSettings.php new file mode 100644 index 0000000..f1df262 --- /dev/null +++ b/tests/TestSupport/Pages/MailSettings.php @@ -0,0 +1,33 @@ +default('info@example.com') + ->label(__('From address')) + ->email() + ->required(), + Setting::make('mail_password') + ->encrypted() + ->password() + ->label(__('Password')), + ]; + } +} diff --git a/tests/TestSupport/Pages/SiteSettings.php b/tests/TestSupport/Pages/SiteSettings.php new file mode 100644 index 0000000..af66231 --- /dev/null +++ b/tests/TestSupport/Pages/SiteSettings.php @@ -0,0 +1,32 @@ +default('Maldives') + ->label(__('Site title')) + ->required() + ->maxLength(100) + ->translatable(), + Setting::make('footer_text') + ->field(Textarea::class) + ->label(__('Footer')) + ->translatable(), + Setting::make('contact_email') + ->default('info@example.com') + ->email(), + ]; + } +} diff --git a/tests/TestSupport/Policies/DenyAllPolicy.php b/tests/TestSupport/Policies/DenyAllPolicy.php new file mode 100644 index 0000000..475ad90 --- /dev/null +++ b/tests/TestSupport/Policies/DenyAllPolicy.php @@ -0,0 +1,11 @@ +loadMigrationsFrom(__DIR__ . '/../database'); + + View::addLocation(__DIR__ . '/../views'); + + Relation::morphMap([ + 'user' => User::class, + 'admin' => Admin::class, + ]); + } +} diff --git a/tests/TestSupport/database/2024_01_01_000000_create_test_users_tables.php b/tests/TestSupport/database/2024_01_01_000000_create_test_users_tables.php new file mode 100644 index 0000000..93cc775 --- /dev/null +++ b/tests/TestSupport/database/2024_01_01_000000_create_test_users_tables.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('admins'); + Schema::dropIfExists('users'); + } +}; diff --git a/tests/TestSupport/database/2024_01_02_000000_create_activity_log_table.php b/tests/TestSupport/database/2024_01_02_000000_create_activity_log_table.php new file mode 100644 index 0000000..68ac1e1 --- /dev/null +++ b/tests/TestSupport/database/2024_01_02_000000_create_activity_log_table.php @@ -0,0 +1,34 @@ +bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->uuid('batch_uuid')->nullable(); + $table->timestamps(); + $table->index('log_name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + } +}; diff --git a/tests/TestSupport/database/2024_01_03_000000_create_jobs_table.php b/tests/TestSupport/database/2024_01_03_000000_create_jobs_table.php new file mode 100644 index 0000000..edac6fe --- /dev/null +++ b/tests/TestSupport/database/2024_01_03_000000_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/tests/TestSupport/database/2024_01_04_000000_create_cache_table.php b/tests/TestSupport/database/2024_01_04_000000_create_cache_table.php new file mode 100644 index 0000000..06dc7a5 --- /dev/null +++ b/tests/TestSupport/database/2024_01_04_000000_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/tests/TestSupport/views/app.blade.php b/tests/TestSupport/views/app.blade.php new file mode 100644 index 0000000..dd10ca1 --- /dev/null +++ b/tests/TestSupport/views/app.blade.php @@ -0,0 +1,5 @@ + + +@inertiaHead +@inertia + diff --git a/tests/TestSupport/views/layouts/app.blade.php b/tests/TestSupport/views/layouts/app.blade.php new file mode 100644 index 0000000..c29060e --- /dev/null +++ b/tests/TestSupport/views/layouts/app.blade.php @@ -0,0 +1,7 @@ + + +{{ __('Settings') }} + +@yield('content') + + diff --git a/tests/Unit/DefinitionsTest.php b/tests/Unit/DefinitionsTest.php new file mode 100644 index 0000000..8c1a50d --- /dev/null +++ b/tests/Unit/DefinitionsTest.php @@ -0,0 +1,232 @@ +required()->maxLength(255)->getField(); + + $this->assertInstanceOf(TextInput::class, $field); + $this->assertEquals('site_name', $field->getName()); + $this->assertTrue($field->isRequired()); + $this->assertContains('max:255', $field->getValidationRules()); + } + + #[Test] + public function it_takes_a_field_class_a_field_or_a_closure(): void + { + $this->assertInstanceOf(Select::class, Setting::make('theme')->field(Select::class)->getField()); + $this->assertInstanceOf(Select::class, Setting::make('theme')->field(Select::make('theme'))->getField()); + $this->assertInstanceOf(Select::class, Setting::make('theme')->field(fn (string $key) => Select::make($key))->getField()); + } + + #[Test] + public function it_applies_field_calls_made_before_the_field_is_set(): void + { + $field = Setting::make('theme')->required()->field(Select::class)->options(['a' => 'A'])->getField(); + + $this->assertTrue($field->isRequired()); + $this->assertStringContainsString('in:"a"', implode('|', array_map('strval', $field->getValidationRules()))); + } + + #[Test] + public function it_builds_a_fresh_field_each_time(): void + { + $setting = Setting::make('site_name')->field(TextInput::make('site_name')); + + $this->assertNotSame($setting->getField(), $setting->getField()); + } + + #[Test] + public function it_rejects_a_field_with_another_name(): void + { + $this->expectException(SettingsException::class); + + Setting::make('site_name')->field(TextInput::make('title'))->getField(); + } + + #[Test] + public function it_rejects_a_class_that_isnt_a_field(): void + { + $this->expectException(SettingsException::class); + + Setting::make('site_name')->field(stdClass::class); + } + + #[Test] + public function it_uploads_file_settings_to_the_configured_disk(): void + { + $this->app['config']->set('settings.disk', 'uploads'); + $this->app['config']->set('settings.directory', 'site'); + + $setting = Setting::make('logo')->field(FileUpload::class); + $field = $setting->getField(); + + $this->assertTrue($setting->isFile()); + $this->assertEquals('uploads', (fn () => $this->disk)->call($field)); + $this->assertEquals('site', (fn () => $this->directory)->call($field)); + } + + #[Test] + public function it_infers_casts_from_the_field(): void + { + $this->assertEquals('integer', Setting::make('a')->integer()->getCast()); + $this->assertEquals('float', Setting::make('a')->numeric()->getCast()); + $this->assertEquals('array', Setting::make('a')->field(Select::class)->multiple()->getCast()); + $this->assertNull(Setting::make('a')->getCast()); + $this->assertEquals('string', Setting::make('a')->integer()->cast('string')->getCast()); + } + + #[Test] + public function it_casts_values(): void + { + $this->assertSame('5', Setting::make('a')->cast('string')->castValue(5)); + $this->assertSame(5.5, Setting::make('a')->cast('float')->castValue('5.5')); + $this->assertSame(['a'], Setting::make('a')->cast('array')->castValue('a')); + $this->assertTrue(Setting::make('a')->cast('boolean')->castValue('yes')); + $this->assertEquals('A', Setting::make('a')->cast(fn ($value) => strtoupper($value))->castValue('a')); + $this->assertNull(Setting::make('a')->cast('integer')->castValue(null)); + } + + #[Test] + public function it_reads_the_settings_of_a_page_from_its_sections(): void + { + $page = new GeneralSettings; + + $this->assertEquals( + ['site_name', 'per_page', 'registration_open', 'theme', 'seo_description', 'seo_keywords', 'logo'], + array_keys($page->settings()), + ); + $this->assertTrue($page->has('seo_keywords')); + $this->assertEquals('general', $page->getKey()); + $this->assertEquals('General', $page->getLabel()); + } + + #[Test] + public function it_turns_sections_into_stencil_sections(): void + { + $components = (new GeneralSettings)->components(); + + $section = collect($components)->first(fn ($component) => $component instanceof StencilSection); + + $this->assertNotNull($section); + $props = $section->getProps(); + + $this->assertEquals('Search engines', $props['heading']); + $this->assertEquals('How the site appears in search results', $props['description']); + $this->assertNotEmpty($props['columns']); + } + + #[Test] + public function it_rejects_anything_else_in_a_page_schema(): void + { + $this->expectException(SettingsException::class); + + Settings::page('broken')->schema([TextInput::make('title')])->settings(); + } + + #[Test] + public function it_registers_inline_pages(): void + { + $page = Settings::page('social') + ->label(fn () => __('Social media')) + ->description('Links in the footer') + ->icon('share') + ->permission('edit_social_settings') + ->order(5) + ->schema(fn () => [ + Section::make('Links')->schema([ + Setting::make('facebook')->default('javaabu'), + ]), + ]); + + $this->assertInstanceOf(InlinePage::class, $page); + $this->assertSame($page, Settings::page('social')); + $this->assertEquals('Social media', $page->getLabel()); + $this->assertEquals('Links in the footer', $page->getDescription()); + $this->assertEquals('share', $page->getIcon()); + $this->assertEquals('edit_social_settings', $page->getPermission()); + $this->assertEquals(5, $page->getOrder()); + $this->assertEquals('javaabu', Settings::get('facebook')); + } + + #[Test] + public function it_orders_pages(): void + { + Settings::page(MailSettings::class); + Settings::page(GeneralSettings::class); + Settings::page('social')->order(5)->schema([]); + + $this->assertEquals(['general', 'social', 'mail'], array_keys(Settings::pages())); + } + + #[Test] + public function it_registers_pages_from_config(): void + { + $this->assertEquals(['general', 'mail'], array_keys(Settings::pages())); + } + + #[Test] + public function it_rejects_a_setting_defined_on_two_pages(): void + { + Settings::page(GeneralSettings::class); + Settings::page('other')->schema([Setting::make('site_name')]); + + $this->expectException(SettingsException::class); + $this->expectExceptionMessage('Setting [site_name] is defined on both the [general] and [other] pages.'); + + Settings::get('site_name'); + } + + #[Test] + public function it_knows_the_page_of_a_setting(): void + { + Settings::page(GeneralSettings::class); + + $this->assertEquals('general', $this->registry()->pageOf('seo_keywords')->getKey()); + $this->assertNull($this->registry()->pageOf('unknown')); + } + + #[Test] + public function it_throws_for_an_unknown_page(): void + { + $this->expectException(SettingsException::class); + + $this->registry()->findOrFail('missing'); + } + + #[Test] + public function it_rejects_page_classes_that_arent_pages(): void + { + $this->expectException(SettingsException::class); + + Settings::page(stdClass::class); + } + + protected function defineEnvironment($app): void + { + parent::defineEnvironment($app); + + if ($this->name() === 'it_registers_pages_from_config') { + $app['config']->set('settings.pages', [MailSettings::class, GeneralSettings::class]); + } + } +} diff --git a/tests/Unit/FakesSettingsTest.php b/tests/Unit/FakesSettingsTest.php index 25f8180..1a1a5fe 100644 --- a/tests/Unit/FakesSettingsTest.php +++ b/tests/Unit/FakesSettingsTest.php @@ -2,21 +2,26 @@ namespace Javaabu\Settings\Tests\Unit; +use Javaabu\Settings\Facades\Settings; use Javaabu\Settings\Testing\FakesSettings; use Javaabu\Settings\Tests\TestCase; +use Javaabu\Settings\Tests\TestSupport\Pages\GeneralSettings; +use PHPUnit\Framework\Attributes\Test; class FakesSettingsTest extends TestCase { use FakesSettings; - public function test_it_can_fake_a_default_setting() + #[Test] + public function it_can_fake_a_default_setting(): void { $this->setFakeDefaultSetting('daily_limit', 20); $this->assertEquals(20, default_setting('daily_limit')); } - public function test_it_can_fake_a_setting_with_null_default() + #[Test] + public function it_can_fake_a_setting_with_null_default(): void { $this->setFakeSetting('app_name', 'fake name', null); @@ -24,7 +29,8 @@ public function test_it_can_fake_a_setting_with_null_default() $this->assertEquals('fake name', get_setting('app_name')); } - public function test_it_can_fake_a_setting() + #[Test] + public function it_can_fake_a_setting(): void { $this->setFakeSetting('app_name', 'fake name'); @@ -32,7 +38,8 @@ public function test_it_can_fake_a_setting() $this->assertEquals('fake name', get_setting('app_name')); } - public function test_it_can_fake_a_setting_with_a_default() + #[Test] + public function it_can_fake_a_setting_with_a_default(): void { $this->setFakeSetting('app_name', 'fake name', 'default name'); @@ -40,7 +47,8 @@ public function test_it_can_fake_a_setting_with_a_default() $this->assertEquals('fake name', get_setting('app_name')); } - public function test_it_can_handle_real_settings_along_with_fake_settings() + #[Test] + public function it_can_handle_real_settings_along_with_fake_settings(): void { $this->app['config']->set('defaults.app_name', 'Actual Name'); @@ -51,4 +59,25 @@ public function test_it_can_handle_real_settings_along_with_fake_settings() $this->assertEquals('Actual Name', get_setting('app_name')); $this->assertEquals(23, get_setting('max_limit')); } + + #[Test] + public function it_fakes_defined_settings_without_touching_the_store(): void + { + Settings::page(GeneralSettings::class); + + Settings::set('site_name', 'Stored'); + + $this->setFakeSettings(['site_name' => 'ދިވެހިރާއްޖެ', 'per_page' => '5']); + $this->setFakeDefaultSetting('registration_open', false); + + $this->assertTrue(Settings::isFaking()); + $this->assertEquals('ދިވެހިރާއްޖެ', settings('site_name')); + $this->assertSame(5, settings('per_page')); + $this->assertFalse(settings('registration_open')); + + Settings::stopFaking(); + + $this->assertEquals('Stored', settings('site_name')); + $this->assertTrue(settings('registration_open')); + } } diff --git a/tests/Unit/TranslationsTest.php b/tests/Unit/TranslationsTest.php new file mode 100644 index 0000000..87dbe5a --- /dev/null +++ b/tests/Unit/TranslationsTest.php @@ -0,0 +1,67 @@ +translatableStrings(), array_keys($dv))); + + $this->assertEquals([], $missing, 'Add these to lang/dv.json'); + $this->assertEquals(array_keys($dv), array_keys($en), 'lang/en.json and lang/dv.json should have the same keys'); + + foreach ($dv as $key => $value) { + $this->assertMatchesRegularExpression('/\p{Thaana}/u', $value, "[{$key}] isn't translated"); + } + } + + #[Test] + public function it_translates_into_dhivehi(): void + { + app()->setLocale('dv'); + + $this->assertEquals('ސެޓިންގްސް ސޭވްކުރެވިއްޖެ.', __('Settings saved.')); + $this->assertEquals('ސެޓިންގްސް ބަދަލުކުރުން', __('Edit settings')); + } + + /** + * Literal strings passed to __() in src and resources, outside comments, + * and the permission descriptions + * + * @return array + */ + protected function translatableStrings(): array + { + $strings = ['View settings', 'Edit settings', 'Settings']; + + foreach (['src', 'resources/views', 'resources/js'] as $directory) { + $path = __DIR__ . "/../../{$directory}"; + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) { + foreach (file($file->getPathname()) as $line) { + if (preg_match('/^\s*(\*|\/\/|\/\*)/', $line)) { + continue; + } + + preg_match_all("/(?])__\(\s*'((?:[^'\\\\]|\\\\.)*)'/", $line, $matches); + + foreach ($matches[1] as $string) { + $strings[] = stripslashes($string); + } + } + } + } + + return array_values(array_unique($strings)); + } +} diff --git a/tests/js/Edit.test.tsx b/tests/js/Edit.test.tsx new file mode 100644 index 0000000..e88c372 --- /dev/null +++ b/tests/js/Edit.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import Edit from "../../resources/js/Pages/Settings/Edit"; +import { SettingsNav } from "../../resources/js/components/SettingsNav"; +import type { SettingsEditProps } from "../../resources/js/types"; +import fixture from "./fixtures/edit-props.json"; + +// The props SettingsController sends (written by InertiaPagesTest with UPDATE_FIXTURES=1) +const props = fixture as unknown as SettingsEditProps; + +describe("settings::Settings/Edit", () => { + it("renders the form of the page with its values", () => { + render(); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Settings"); + expect(screen.getByLabelText(/Site name/)).toHaveValue("ދިވެހިރާއްޖެ"); + expect(screen.getByLabelText(/Items per page/)).toHaveValue(20); + expect(screen.getByRole("button", { name: "Save settings" })).toBeInTheDocument(); + }); + + it("types Dhivehi into Thaana fields right to left", () => { + render(); + + const input = screen.getByLabelText(/Site name/); + + expect(input).toHaveAttribute("dir", "rtl"); + expect(input).toHaveAttribute("lang", "dv"); + }); + + it("links to the other pages", () => { + render(); + + expect(screen.getByRole("link", { name: "Mail" })).toHaveAttribute("href", props.pages[1]?.url); + }); + + it("shows the status after saving and the read only notice", () => { + render( + , + ); + + expect(screen.getByText("ސެޓިންގްސް ސޭވްކުރެވިއްޖެ.")).toBeInTheDocument(); + expect(screen.getByText("Read only")).toBeInTheDocument(); + }); +}); + +describe("SettingsNav", () => { + it("renders nothing for a single page", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/tests/js/fixtures/edit-props.json b/tests/js/fixtures/edit-props.json new file mode 100644 index 0000000..4d4c9e4 --- /dev/null +++ b/tests/js/fixtures/edit-props.json @@ -0,0 +1,174 @@ +{ + "form": { + "stencil": 1, + "id": "settings-general", + "action": "http://localhost/settings/general", + "method": "PATCH", + "files": true, + "operation": "edit", + "submit": "Save settings", + "state": { + "site_name": "ދިވެހިރާއްޖެ", + "per_page": 20, + "registration_open": true, + "theme": "light", + "seo_description": null, + "seo_keywords": [ + "laravel", + "javaabu" + ], + "logo": "http://localhost/img/logo.png" + }, + "live": null, + "components": [ + { + "type": "text", + "key": "site_name", + "name": "site_name", + "component": "Text", + "props": { + "label": "Site name", + "required": true, + "thaana": true, + "maxLength": 255 + } + }, + { + "type": "text", + "key": "per_page", + "name": "per_page", + "component": "Number", + "props": { + "label": "Items per page", + "required": true, + "min": 1, + "max": 100, + "step": 1 + } + }, + { + "type": "checkbox", + "key": "registration_open", + "name": "registration_open", + "component": "Checkbox", + "props": { + "label": "Registration is open" + } + }, + { + "type": "select", + "key": "theme", + "name": "theme", + "component": "Select", + "props": { + "label": "Theme", + "required": true, + "options": [ + { + "value": "light", + "label": "Light" + }, + { + "value": "dark", + "label": "Dark" + } + ] + } + }, + { + "type": "section", + "key": "section-4", + "props": { + "heading": "Search engines", + "description": "How the site appears in search results", + "columns": { + "default": 1, + "md": 2 + } + }, + "children": [ + { + "type": "textarea", + "key": "seo_description", + "name": "seo_description", + "component": "Textarea", + "props": { + "label": "Description", + "thaana": true + } + }, + { + "type": "checkbox-list", + "key": "seo_keywords", + "name": "seo_keywords[]", + "component": "Checkbox", + "props": { + "label": "Seo Keywords", + "options": [ + { + "value": "laravel", + "label": "Laravel" + }, + { + "value": "javaabu", + "label": "Javaabu" + }, + { + "value": "dhivehi", + "label": "ދިވެހި" + } + ] + } + } + ] + }, + { + "type": "image", + "key": "logo", + "name": "logo", + "component": "Image", + "props": { + "label": "Logo", + "type": "image", + "default": "http://localhost/img/logo.png" + } + } + ] + }, + "page": { + "key": "general", + "label": "General", + "description": "The name and look of the site", + "icon": null, + "url": "http://localhost/settings/general", + "can_update": true + }, + "pages": [ + { + "key": "general", + "label": "General", + "description": "The name and look of the site", + "icon": null, + "url": "http://localhost/settings/general", + "can_update": true, + "active": true + }, + { + "key": "mail", + "label": "Mail", + "description": null, + "icon": null, + "url": "http://localhost/settings/mail", + "can_update": true, + "active": false + } + ], + "status": null, + "strings": { + "title": "Settings", + "read_only": "You can view these settings but not change them.", + "pages": "Settings pages" + }, + "layout": "layouts.app", + "layout_section": "content" +} diff --git a/tests/js/raw.d.ts b/tests/js/raw.d.ts new file mode 100644 index 0000000..3520f9a --- /dev/null +++ b/tests/js/raw.d.ts @@ -0,0 +1,5 @@ +// stencil's renderer imports its stylesheet with Vite's ?raw suffix +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/tests/js/setup.ts b/tests/js/setup.ts new file mode 100644 index 0000000..01bd2e2 --- /dev/null +++ b/tests/js/setup.ts @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom/vitest"; + +// jsdom has no matchMedia; Onu's components use it for reduced motion +if (!window.matchMedia) { + window.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..343dcfb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vitest/globals", "@testing-library/jest-dom"], + "paths": { + "@javaabu/settings": ["./resources/js"], + "@javaabu/settings/*": ["./resources/js/*"], + "@javaabu/stencil": ["./vendor/javaabu/stencil/resources/js"], + "@javaabu/stencil/*": ["./vendor/javaabu/stencil/resources/js/*"] + } + }, + "include": ["resources/js", "tests/js"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d6cf58d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +// @javaabu/stencil is stencil's React renderer in vendor/ (composer install first), +// as javaabu/manifest's Vite plugin resolves it in apps +export default defineConfig({ + resolve: { + alias: { + "@javaabu/settings": new URL("./resources/js", import.meta.url).pathname, + "@javaabu/stencil": new URL("./vendor/javaabu/stencil/resources/js", import.meta.url).pathname, + }, + }, + test: { + environment: "jsdom", + globals: true, + include: ["tests/js/**/*.test.{ts,tsx}"], + setupFiles: ["tests/js/setup.ts"], + }, +});