diff --git a/composer.json b/composer.json index 7e975f6a3..70bb7ba9e 100755 --- a/composer.json +++ b/composer.json @@ -50,12 +50,14 @@ "laravel/telescope": "^4.0|^5.0", "laravel/ui": "^4.2", "league/glide-laravel": "^1.0", + "mcamara/laravel-localization": "^1.8|^2.0", "nwidart/laravel-modules": "^8.0|^9.0|^10.0", "oobook/manage-eloquent": "^1.0", "oobook/post-redirector": "^1.0", "oobook/priceable": "^1.0", "oobook/snapshot": "^2.0", "orangehill/iseed": "^3.0", + "pragmarx/google2fa": "^8.0", "spatie/laravel-activitylog": "^3.0|^4.0", "spatie/laravel-permission": "^5.0", "spatie/once": "^2.0|^3.0", diff --git a/composer.lock b/composer.lock index 2ebacf589..237fa8a40 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3a4e863c64d14a2fb43830ee4dc8b21b", + "content-hash": "001a502ff50e0b1229d479a43ee8e1ed", "packages": [ { "name": "astrotomic/laravel-translatable", @@ -3434,6 +3434,82 @@ }, "time": "2025-02-08T13:14:57+00:00" }, + { + "name": "mcamara/laravel-localization", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/mcamara/laravel-localization.git", + "reference": "af91f489f518fb1907944de8622a19266159d28f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mcamara/laravel-localization/zipball/af91f489f518fb1907944de8622a19266159d28f", + "reference": "af91f489f518fb1907944de8622a19266159d28f", + "shasum": "" + }, + "require": { + "laravel/framework": "^10.0|^11.0|^12.0", + "php": "^8.2" + }, + "require-dev": { + "orchestra/testbench-browser-kit": "^8.5|^9.0|^10.0", + "phpunit/phpunit": "^10.1|^11.0" + }, + "suggest": { + "ext-intl": "*" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "LaravelLocalization": "Mcamara\\LaravelLocalization\\Facades\\LaravelLocalization" + }, + "providers": [ + "Mcamara\\LaravelLocalization\\LaravelLocalizationServiceProvider" + ] + } + }, + "autoload": { + "psr-0": { + "Mcamara\\LaravelLocalization": "src/" + }, + "classmap": [] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marc Cámara", + "email": "mcamara88@gmail.com", + "role": "Developer" + } + ], + "description": "Easy localization for Laravel", + "homepage": "https://github.com/mcamara/laravel-localization", + "keywords": [ + "laravel", + "localization", + "php" + ], + "support": { + "issues": "https://github.com/mcamara/laravel-localization/issues", + "source": "https://github.com/mcamara/laravel-localization/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/iwasherefirst2", + "type": "github" + }, + { + "url": "https://github.com/mcamara", + "type": "github" + } + ], + "time": "2025-02-26T06:38:01+00:00" + }, { "name": "moneyphp/money", "version": "v4.7.0", @@ -4660,6 +4736,58 @@ ], "time": "2024-12-14T21:12:59+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v8.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + }, + "time": "2024-09-05T11:56:40+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -12175,5 +12303,5 @@ "php": ">=8.1" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/config/config.php b/config/config.php index be75a8cb2..79a6d93ed 100755 --- a/config/config.php +++ b/config/config.php @@ -22,6 +22,7 @@ 'admin_route_name_prefix' => env('ADMIN_ROUTE_NAME_PREFIX', 'admin'), 'app_theme' => env('VUE_APP_THEME', 'unusualify'), 'available_user_locales' => explode(',', env('MODULARITY_AVAILABLE_USER_LOCALES', 'en')), + 'default_register_role' => env('MODULARITY_DEFAULT_REGISTER_ROLE', 'client-manager'), 'version' => '1.0.0', 'auth_login_redirect_path' => '/', diff --git a/config/defers/auth_pages.php b/config/defers/auth_pages.php index 043adef5f..97665f201 100644 --- a/config/defers/auth_pages.php +++ b/config/defers/auth_pages.php @@ -47,6 +47,38 @@ 'formSlotsPreset' => 'login_options', 'slotsPreset' => 'login_bottom', ], + 'login_mfa' => [ + 'pageTitle' => 'authentication.login', + 'layoutPreset' => 'minimal', + 'formDraft' => 'login_email_form', + 'actionRoute' => 'admin.login', + 'formTitle' => 'authentication.login-title', + 'buttonText' => 'authentication.sign-in', + 'formSlotsPreset' => 'login_mfa_options', + 'slotsPreset' => 'login_mfa_bottom', + ], + 'login_2fa' => [ + 'pageTitle' => 'authentication.verify-login', + 'layoutPreset' => 'minimal', + 'formDraft' => 'login_2fa_form', + 'actionRoute' => 'admin.login-2fa', + 'formTitle' => 'authentication.verify-login', + 'buttonText' => 'authentication.login', + 'formSlotsPreset' => 'login_2fa_options', + 'formOverrides' => ['noValidation' => true], + 'slotsPreset' => null, + ], + 'step_up' => [ + 'pageTitle' => 'authentication.verify-login', + 'layoutPreset' => 'minimal', + 'formDraft' => 'step_up_form', + 'actionRoute' => 'admin.step-up.verify', + 'formTitle' => 'authentication.verify-login', + 'buttonText' => 'authentication.login', + 'formSlotsPreset' => 'step_up_options', + 'formOverrides' => ['noValidation' => true, 'async' => false], + 'slotsPreset' => null, + ], 'register' => [ 'pageTitle' => 'authentication.register', 'layoutPreset' => 'banner', diff --git a/config/defers/form_drafts.php b/config/defers/form_drafts.php index ebefca717..319dbdab5 100755 --- a/config/defers/form_drafts.php +++ b/config/defers/form_drafts.php @@ -310,6 +310,48 @@ 'hideDetails' => 'auto', ], ], + 'login_email_form' => [ + 'email' => [ + 'type' => 'text', + 'name' => 'email', + 'label' => 'E-mail', + 'hint' => 'enter @example.com', + 'default' => '', + 'col' => [ + 'lg' => 12, + ], + 'rules' => [ + ['email', '', 'E-mail must be valid'], + ], + 'validateOn' => 'lazy blur', + ], + ], + 'login_2fa_form' => [ + 'verify_code' => [ + 'type' => 'otp-input', + 'name' => 'verify-code', + 'label' => 'One Time Password', + 'default' => '', + 'col' => [ + 'lg' => 12, + ], + 'length' => env('MODULARITY_SECURITY_MFA_EMAIL_OTP_LENGTH', 6), + 'rules' => 'required', + ], + ], + 'step_up_form' => [ + 'verify_code' => [ + 'type' => 'otp-input', + 'name' => 'verify-code', + 'label' => 'Verification Code', + 'default' => '', + 'col' => [ + 'lg' => 12, + ], + 'length' => 6, + 'rules' => 'required', + ], + ], 'forgot_password_form' => [ 'email' => [ 'type' => 'text', diff --git a/config/defers/navigation.php b/config/defers/navigation.php index 6d49efafc..c8e79751b 100755 --- a/config/defers/navigation.php +++ b/config/defers/navigation.php @@ -30,6 +30,21 @@ 'icon' => '$header', ], ...Navigation::modulesMenu(), + // '_cms_promotion' => [ + // 'name' => 'CMS promotion', + // 'icon' => 'mdi-rocket-launch-outline', + // 'route_name' => 'admin.system.cms.promotion.tool', + // ], + // '_cms_site_seo' => [ + // 'name' => 'Site SEO', + // 'icon' => 'mdi-robot-outline', + // 'route_name' => 'admin.system.cms.siteSeo.tool', + // ], + // '_cms_sitemap' => [ + // 'name' => 'Sitemap', + // 'icon' => 'mdi-sitemap', + // 'route_name' => 'admin.system.cms.sitemap.index', + // ], '_system_settings' => [ 'name' => 'System Settings', 'icon' => '$header', diff --git a/config/merges/cms_features.php b/config/merges/cms_features.php new file mode 100644 index 000000000..1f354162d --- /dev/null +++ b/config/merges/cms_features.php @@ -0,0 +1,9 @@ + env('MODULARITY_CMS_FEATURES_ENABLED', true), + 'register_contracts' => env('MODULARITY_CMS_REGISTER_CONTRACTS', true), + 'register_middlewares' => env('MODULARITY_CMS_REGISTER_MIDDLEWARES', true), +]; diff --git a/config/merges/cms_parent_segments.php b/config/merges/cms_parent_segments.php new file mode 100644 index 000000000..096d14474 --- /dev/null +++ b/config/merges/cms_parent_segments.php @@ -0,0 +1,12 @@ + env('MODULARITY_CMS_PARENT_SEGMENTS_ENABLED', true), +]; diff --git a/config/merges/cms_promotion.php b/config/merges/cms_promotion.php new file mode 100644 index 000000000..aae7b9346 --- /dev/null +++ b/config/merges/cms_promotion.php @@ -0,0 +1,45 @@ + env('MODULARITY_CMS_PROMOTION_ENABLED', false), + + 'dry_run_required' => env('MODULARITY_CMS_PROMOTION_DRY_RUN_REQUIRED', true), + + 'scope' => [ + 'settings' => env('MODULARITY_CMS_PROMOTION_SCOPE_SETTINGS', true), + 'content' => env('MODULARITY_CMS_PROMOTION_SCOPE_CONTENT', true), + 'seo' => env('MODULARITY_CMS_PROMOTION_SCOPE_SEO', true), + 'redirects' => env('MODULARITY_CMS_PROMOTION_SCOPE_REDIRECTS', true), + 'layouts' => env('MODULARITY_CMS_PROMOTION_SCOPE_LAYOUTS', true), + ], + + 'approval' => [ + 'enabled' => env('MODULARITY_CMS_PROMOTION_APPROVAL_ENABLED', true), + 'roles' => array_filter(array_map('trim', explode(',', env('MODULARITY_CMS_PROMOTION_APPROVER_ROLES', 'superadmin,admin')))), + 'emails' => array_filter(array_map('trim', explode(',', env('MODULARITY_CMS_PROMOTION_APPROVER_EMAILS', '')))), + 'checkpoint_label' => env('MODULARITY_CMS_PROMOTION_CHECKPOINT_LABEL', 'cms-promotion-approval'), + ], + + /** + * Optional second Laravel DB connection (e.g. `staging`, `mysql_prod`) for dry-run **count deltas** vs default. + * Does not copy data — read-only snapshots. Restrict with `allowed_connections` in production. + */ + 'compare' => [ + 'connection' => env('MODULARITY_CMS_PROMOTION_COMPARE_CONNECTION', ''), + 'label' => env('MODULARITY_CMS_PROMOTION_COMPARE_LABEL', 'target'), + /** Non-empty = only these connection names may be used for compare (e.g. ['staging','mysql_uat']) */ + 'allowed_connections' => array_values(array_filter(array_map('trim', explode(',', env('MODULARITY_CMS_PROMOTION_COMPARE_ALLOWED', ''))))), + /** When true, include full secondary snapshot in API (large JSON). Default: deltas only. */ + 'include_full_target_snapshot' => env('MODULARITY_CMS_PROMOTION_COMPARE_INCLUDE_TARGET', false), + ], + + 'execute' => [ + /** When true, runs Cache::flush() after modularity cache (use with care). */ + 'flush_laravel_cache' => env('MODULARITY_CMS_PROMOTION_FLUSH_LARAVEL_CACHE', false), + ], + + 'audit' => [ + 'activity_log' => env('MODULARITY_CMS_PROMOTION_AUDIT_ACTIVITY', true), + 'log_channel' => env('MODULARITY_CMS_PROMOTION_AUDIT_LOG_CHANNEL', 'modularity'), + ], +]; diff --git a/config/merges/cms_routing.php b/config/merges/cms_routing.php new file mode 100644 index 000000000..d735cafaf --- /dev/null +++ b/config/merges/cms_routing.php @@ -0,0 +1,209 @@ + env('MODULARITY_CMS_LOCALIZATION_DRIVER', 'auto'), + + /** + * Public front catch-all shape: {@code catch_all} = single {@code {path}} (locale in first segment, resolved in PHP); + * {@code locale_param} = {@code {locale}/{path}} route group (mcamara-friendly; requires mcamara stack). + * Per-locale URL shapes are **not** duplicated via mcamara {@code transRoute('routes.*')} + lang files: they come + * from {@see \Modules\Cms\Entities\UrlRoute} + {@see \Modules\Cms\Entities\ParentSegment} at runtime. + * + * @see \Modules\Cms\Routing\CmsFrontRouteLocalizationBinding + */ + 'public_front_route_group_mode' => env('MODULARITY_CMS_PUBLIC_FRONT_ROUTE_GROUP_MODE', 'catch_all'), + + /** + * When {@see public_front_route_group_mode} is {@code locale_param}, append {@code LaravelLocalizationRoutes}. + */ + 'public_front_mcamara_middleware_with_locale_param' => env('MODULARITY_CMS_PUBLIC_FRONT_MCAMARA_MW_LOCALE_PARAM', true), + + /** + * When using {@code catch_all}, optionally append mcamara {@code LaravelLocalizationRoutes} (usually false). + */ + 'public_front_mcamara_middleware_with_catch_all' => env('MODULARITY_CMS_PUBLIC_FRONT_MCAMARA_MW_CATCH_ALL', false), + + /** + * When {@code mcamara}: read hide-default from {@code config('laravellocalization.hideDefaultLocaleInURL')}. + * When {@code cms}: use {@see hide_default_locale_segment}. + * When {@code both}: logical OR of mcamara + CMS flags. + */ + 'localization_hide_default_source' => env('MODULARITY_CMS_LOCALIZATION_HIDE_DEFAULT_SOURCE', 'mcamara'), + + 'locale_strategy' => env('MODULARITY_CMS_LOCALE_STRATEGY', 'path'), + 'canonical_host' => env('MODULARITY_CMS_CANONICAL_HOST', parse_url((string) env('APP_URL', ''), PHP_URL_HOST)), + 'default_locale' => env('MODULARITY_CMS_DEFAULT_LOCALE', env('APP_LOCALE', 'en')), + 'hide_default_locale_segment' => env('MODULARITY_CMS_HIDE_DEFAULT_LOCALE_SEGMENT', false), + + /** + * When **true**, the single “slugless fallback” locale (see {@see fallback_locale_optional_path_segment_locale}) is used + * as the implicit locale for URLs with no `/locale/` prefix — e.g. {@code /pages/test} resolves like {@code fallback} content — + * and {@code GET /en/pages/test} redirects to strip {@code /en} when {@see UrlRoute} already serves {@code PAGE_PUBLIC}. + * + * @see \Modules\Cms\Support\CmsSluglessFallbackLocale + * @see \Modules\Cms\Http\Middleware\FallbackLocaleSluglessCanonicalMiddleware + */ + 'fallback_locale_optional_path_segment' => env('MODULARITY_CMS_FALLBACK_LOCALE_OPTIONAL_PATH_SEGMENT', false), + + /** + * Explicit locale override for slugless-canonical URLs. {@code null} / empty env → {@code config('translatable.fallback_locale')} + * then {@see default_locale}. + */ + 'fallback_locale_optional_path_segment_locale' => env('MODULARITY_CMS_FALLBACK_LOCALE_OPTIONAL_LOCALE') ?: null, + + /** + * HTTP status for stripping {@code /{slugless}/…} duplicates (typically **301 Moved Permanently** for SEO; **308** preserves method). + * Clamped to 301–308. + */ + 'fallback_locale_explicit_segment_redirect_status' => max( + 301, + min(308, (int) env('MODULARITY_CMS_FALLBACK_LOCALE_EXPLICIT_SEGMENT_REDIRECT_STATUS', 301)), + ), + 'domain_per_locale' => [ + // Example: 'en' => 'example.com', 'tr' => 'example.com.tr' + ], + 'redirect_to_canonical' => env('MODULARITY_CMS_REDIRECT_TO_CANONICAL', false), + + /** + * Optional: restrict public CMS catch-all routes to this host only (highest priority). + * When unset, behaviour depends on {@see public_front_routes_allow_any_host}: if false, routes bind to the host + * parsed from {@code config('app.url')}; if true, routes match any Host header. + * + * @see \Modules\Cms\Routing\CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain + * @see \Modules\Cms\Support\CmsPublicSiteUrl + */ + 'public_front_route_domain' => env('MODULARITY_CMS_PUBLIC_FRONT_ROUTE_DOMAIN'), + + /** + * When **true**, CMS public catch-all routes are registered **without** {@see Route::domain()} (every host matches). + * When **false** (default), and {@see public_front_route_domain} is empty, routes bind to {@code parse_url(config('app.url'), PHP_URL_HOST)}. + * + * Env: {@code MODULARITY_CMS_PUBLIC_FRONT_ROUTES_ALLOW_ANY_HOST} + */ + 'public_front_routes_allow_any_host' => env('MODULARITY_CMS_PUBLIC_FRONT_ROUTES_ALLOW_ANY_HOST', false), + + /** + * @deprecated Prefer {@see public_front_routes_allow_any_host}. When this key is still present in a published config, + * {@code false} behaves like {@code public_front_routes_allow_any_host=true} and {@code true} like {@code false}. + * Omit in new projects. + */ + 'bind_public_routes_to_app_url_host' => env('MODULARITY_CMS_BIND_PUBLIC_ROUTES_TO_APP_URL_HOST'), + + /** + * Explicit list of locale codes allowed as the first path segment ({@code /en/...}, {@code /tr/...}). + * When empty, uses mcamara/laravel-localization supported keys if installed, else {@see getLocales()} / translatable. + * + * @var list|null + */ + 'path_segment_locales' => null, + + /** First segment of public CMS routes; must match module `url` / Route::prefix (see `modules/Cms/Routes/front.php`). */ + 'front_route_prefix' => env('MODULARITY_CMS_FRONT_ROUTE_PREFIX', ''), + + /** Serve public pages when {@see \Modules\Cms\Routing\CmsFrontRouteRegistrar} resolves a front controller (see {@see \Modules\Cms\Http\Controllers\Front\PageController}). */ + 'public_pages_enabled' => env('MODULARITY_CMS_PUBLIC_PAGES_ENABLED', true), + + /** + * When true, {@see \Modules\Cms\Providers\CmsRouteServiceProvider} registers public catch-all routes for each + * enabled module whose submodule resolves a {@code Http/Controllers/Front/{Route}Controller} extending + * {@see \Modules\Cms\Http\Controllers\Front\CmsController} (see {@see \Modules\Cms\Routing\CmsFrontRouteRegistrar::resolveFrontControllerForModule()}). + */ + 'auto_register_public_front' => env('MODULARITY_CMS_AUTO_REGISTER_PUBLIC_FRONT', true), + + /** + * When true, the Cms public catch-all uses {@see \Modules\Cms\Http\Controllers\Front\CmsPublicFrontController} + * and {@see \Modules\Cms\Services\CmsPublicModelResolver::resolveForParentSegmentRegistry()}: any + * {@link \Modules\Cms\Entities\UrlRoute} line whose urlable is an enabled {@link \Modules\Cms\Entities\ParentSegment} + * target with {@link \Unusualify\Modularity\Entities\Traits\HasParentSegment}. When false, the first + * {@code front-controller/...} per submodule (legacy) is used. {@see public_front_handlers} is then honored + * again for {@see \Modules\Cms\Routing\CmsFrontRouteRegistrar::resolveFrontControllerForModelClass()}. + */ + 'universal_cms_public_front' => env('MODULARITY_CMS_UNIVERSAL_PUBLIC_FRONT', true), + + /** + * Optional: model FQCN => Blade name when the automatic {@code cms::{route}.custom} discovery is not enough. + * + * @var array + */ + 'public_front_views_by_model' => [], + + /** + * If submodule discovery and {@see public_front_views_by_model} do not match the resolved model. + */ + 'universal_public_front_fallback_view' => 'cms::page.custom', + + /** + * Optional override: {@see \Modules\Cms\Entities\ParentSegment} {@code target_model_class} FQCN → invokable front + * controller (must extend {@see \Modules\Cms\Http\Controllers\Front\CmsController}). When empty, resolution uses + * {@see \Unusualify\Modularity\Module::getTargetClassNamespace()} with {@code front-controller} + {@code {StudlyRoute}Controller}. + * Ignored for the public catch-all when {@see universal_cms_public_front} is true. + * + * @var array + */ + 'public_front_handlers' => [], + + /** Apply {@see \Modules\Cms\Http\Middleware\VisitorRedirectMiddleware} on the CMS front stack. */ + 'visitor_redirects_enabled' => env('MODULARITY_CMS_VISITOR_REDIRECTS_ENABLED', true), + + /** + * Admin panel (slug validation, path preview). + * + * @see \Modules\Cms\Services\CmsSlugInputValidationService + * @see \Unusualify\Modularity\Hydrates\Inputs\SlugHydrate + */ + 'admin' => [ + 'slug_nested_path_warnings' => env('MODULARITY_CMS_ADMIN_SLUG_NESTED_WARNINGS', true), + 'slug_public_path_preview' => env('MODULARITY_CMS_ADMIN_SLUG_PUBLIC_PATH_PREVIEW', true), + /** + * Max number of URL path segments allowed in the slug field (split on `/`), after trimming. + * `null` = no limit (default). Set to `1` to forbid nested paths such as `parent/child` in admin. + * + * @see \Modules\Cms\Services\CmsSlugInputValidationService + */ + 'slug_max_path_segments' => env('MODULARITY_CMS_ADMIN_SLUG_MAX_PATH_SEGMENTS') !== null + && env('MODULARITY_CMS_ADMIN_SLUG_MAX_PATH_SEGMENTS') !== '' + ? max(1, (int) env('MODULARITY_CMS_ADMIN_SLUG_MAX_PATH_SEGMENTS')) + : null, + ], + + /** + * When a {@see \Modules\Cms\Entities\ParentSegment} row is created/updated/deleted, re-sync {@see \Modules\Cms\Entities\UrlRoute} + * for all instances of {@code target_model_class} ({@see CmsUrlRouteRegistry::syncPublicPageRoutesForAllModelsOfClass}). + */ + 'resync_registry_after_parent_segments_change' => env('MODULARITY_CMS_RESYNC_URL_ROUTES_AFTER_PARENT_SEGMENTS_CHANGE', true), + + /** + * Batch size when walking models after parent-segment edits (Slug / IsSingular targets only). + */ + 'parent_segment_change_resync_chunk_size' => max(1, (int) env('MODULARITY_CMS_PARENT_SEGMENT_RESYNC_CHUNK', 100)), + + /** + * Additional slash-trimmed path prefixes the CMS public catch-all `{path}` must ignore so other {@code Route::get} + * endpoints can answer (beyond {@see signed_preview.path_prefix}, which is always excluded when previews are enabled). + * + * @var list + * + * @see \Modules\Cms\Routing\CmsFrontRouteRegistrar::catchAllPathParameterPattern() + */ + 'public_front_catch_all_exclude_path_prefixes' => [], + + /** + * Time-limited signed URLs for sharing CMS page preview without a panel session. + * + * @see \Modules\Cms\Http\Controllers\CmsSignedPublicPreviewController + * @see \Modules\Cms\Services\CmsSignedPreviewUrlGenerator + */ + 'signed_preview' => [ + 'enabled' => env('MODULARITY_CMS_SIGNED_PREVIEW_ENABLED', true), + 'path_prefix' => trim((string) env('MODULARITY_CMS_SIGNED_PREVIEW_PATH_PREFIX', 'cms/preview'), '/'), + 'ttl_minutes' => max(5, (int) env('MODULARITY_CMS_SIGNED_PREVIEW_TTL_MINUTES', 60)), + /** Laravel throttle: max attempts per decay minutes (per IP) for the signed preview route. */ + 'throttle_max_attempts' => max(1, (int) env('MODULARITY_CMS_SIGNED_PREVIEW_THROTTLE_MAX', 120)), + 'throttle_decay_minutes' => max(1, (int) env('MODULARITY_CMS_SIGNED_PREVIEW_THROTTLE_DECAY', 1)), + ], +]; diff --git a/config/merges/cms_schedule.php b/config/merges/cms_schedule.php new file mode 100644 index 000000000..f1785b616 --- /dev/null +++ b/config/merges/cms_schedule.php @@ -0,0 +1,55 @@ + env('MODULARITY_CMS_SCHEDULE_ENABLED', true), + + /** + * When true, {@see \Modules\Cms\Providers\CmsServiceProvider} registers + * {@see \Modules\Cms\Jobs\ScanCmsPublishWindowBoundariesJob} with Laravel's schedule (opt-in to avoid surprise cron work). + */ + 'register_with_laravel_schedule' => env('MODULARITY_CMS_SCHEDULE_REGISTER', false), + + /** How often the scan runs when registered with the schedule. */ + 'frequency' => env('MODULARITY_CMS_SCHEDULE_FREQUENCY', 'everyFiveMinutes'), + + /** + * Look back window (minutes): rows whose {@code publish_*} timestamp fell in (now - window, now] are considered to have + * just crossed the boundary. Should be ≥ scheduler interval to avoid gaps. + */ + 'boundary_window_minutes' => max(1, (int) env('MODULARITY_CMS_SCHEDULE_BOUNDARY_WINDOW', 6)), + + /** + * Eloquent models scanned for {@code publish_start_date} / {@code publish_end_date} (columns optional per table). + * Override in host config; default is CMS {@see Page} only. + * + * @var list> + */ + 'publish_window_models' => (static function (): array { + $raw = env('MODULARITY_CMS_PUBLISH_WINDOW_MODELS'); + if (! is_string($raw) || $raw === '') { + return [Page::class]; + } + + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + })(), + + /** Log one info line per boundary event when true. */ + 'log_events' => env('MODULARITY_CMS_SCHEDULE_LOG', false), + + /** + * Optional cache tag names to flush when any boundary is detected (Redis taggable cache only; ignored otherwise). + * + * @var list|null + */ + 'cache_flush_tags' => env('MODULARITY_CMS_SCHEDULE_CACHE_TAGS') !== null && env('MODULARITY_CMS_SCHEDULE_CACHE_TAGS') !== '' + ? array_values(array_filter(array_map('trim', explode(',', (string) env('MODULARITY_CMS_SCHEDULE_CACHE_TAGS'))))) + : null, +]; diff --git a/config/merges/cms_seo.php b/config/merges/cms_seo.php new file mode 100644 index 000000000..73e023474 --- /dev/null +++ b/config/merges/cms_seo.php @@ -0,0 +1,48 @@ + [ + 'force_lowercase_path' => env('MODULARITY_CMS_SEO_CANONICAL_FORCE_LOWERCASE', true), + 'trim_trailing_slash' => env('MODULARITY_CMS_SEO_CANONICAL_TRIM_TRAILING_SLASH', true), + ], + + /** + * Global robots.txt (served at GET /robots.txt when route enabled). + * + * @see \Modules\Cms\Http\Controllers\Front\RobotsTxtController + */ + 'robots' => [ + 'route_enabled' => env('MODULARITY_CMS_ROBOTS_TXT_ROUTE_ENABLED', true), + 'global_robots_txt' => env('MODULARITY_CMS_SEO_GLOBAL_ROBOTS_TXT', ''), + /** + * When true, GET /robots.txt prefers {@see \Modules\Cms\Services\CmsSiteSeoSettingsService} (um_cms_site_settings). + * When false, only env/config {@code global_robots_txt} is used (legacy / headless deploys). + */ + 'use_site_settings' => env('MODULARITY_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true), + /** + * Composite key for the global robots.txt body row (must match unique index on site_settings). + */ + 'site_setting' => [ + 'group_key' => env('MODULARITY_CMS_SEO_ROBOTS_SITE_GROUP', 'seo'), + 'key' => env('MODULARITY_CMS_SEO_ROBOTS_SITE_KEY', 'global_robots_txt'), + 'locale' => env('MODULARITY_CMS_SEO_ROBOTS_SITE_LOCALE', '*'), + ], + ], + + /** + * Panel: soft checks when saving a published {@see \Modules\Cms\Entities\Page}. + * + * @see \Modules\Cms\Services\CmsAdminWarnings + */ + 'admin' => [ + 'publish_soft_warnings' => env('MODULARITY_CMS_ADMIN_SEO_PUBLISH_SOFT_WARNINGS', true), + /** + * When true, saving a published {@see \Modules\Cms\Entities\Page} shows a soft warning if "now" is outside the optional publish window + * (visitors already get 404 via {@see \Unusualify\Modularity\Entities\Traits\Core\HasScopes::scopeVisible} on public routes). + */ + 'publish_schedule_warnings' => env('MODULARITY_CMS_ADMIN_PUBLISH_SCHEDULE_WARNINGS', true), + ], +]; diff --git a/config/merges/cms_sitemap.php b/config/merges/cms_sitemap.php new file mode 100644 index 000000000..ca131b127 --- /dev/null +++ b/config/merges/cms_sitemap.php @@ -0,0 +1,50 @@ + env('MODULARITY_CMS_SITEMAP_ROUTE_ENABLED', true), + + /** + * Cache key (prefixed with Laravel's cache store prefix) for the committed sitemap body. + */ + 'cache_key' => env('MODULARITY_CMS_SITEMAP_CACHE_KEY', 'modularity_cms_sitemap.committed_v1'), + + /** + * When no committed build exists yet, optionally run a build synchronously on the first public request + * (avoids an empty index). Default false: serve an empty but valid until a rebuild/commit. + */ + 'build_on_cache_miss' => env('MODULARITY_CMS_SITEMAP_BUILD_ON_MISS', false), + + /** + * Default {@see \Modules\Cms\Entities\Sitemap} row (migration seeds id = 1, slug = default). + */ + 'default_sitemap_id' => (int) env('MODULARITY_CMS_SITEMAP_DEFAULT_ID', 1), + + /** + * Default XML sitemap `changefreq` / `priority` when no per-model row in `cms_sitemapables`. + * {@see \Modules\Cms\Services\CmsSitemapBuildService} + */ + 'defaults' => [ + 'changefreq' => env('MODULARITY_CMS_SITEMAP_DEFAULT_CHANGEFREQ', 'weekly'), + 'priority' => (float) env('MODULARITY_CMS_SITEMAP_DEFAULT_PRIORITY', 0.5), + ], + + /** + * `lastmod` source: the owning model’s `updated_at` (per-locale sitemap line still uses the same stamp). + * Translation-only updates should touch the base model in your save pipeline when you need that reflected. + */ + 'lastmod' => [ + 'source' => 'model', + ], + + /** + * Panel tool ({@see \Modules\Cms\Http\Controllers\SitemapToolController}): step-up ability for **commit** (dry-run is read-only). + */ + 'panel' => [ + 'step_up_ability' => [ + 'commit' => env('MODULARITY_CMS_SITEMAP_PANEL_STEP_UP_COMMIT', 'sitemap.commit'), + ], + ], +]; diff --git a/config/merges/security.php b/config/merges/security.php new file mode 100644 index 000000000..19e6994f9 --- /dev/null +++ b/config/merges/security.php @@ -0,0 +1,77 @@ + env('MODULARITY_SECURITY_ENABLED', false), + + // Managed dynamically from SystemUser/Capability route. + 'capabilities' => [], + + 'mfa' => [ + 'enabled' => env('MODULARITY_SECURITY_MFA_ENABLED', false), + 'required_roles' => array_filter(array_map('trim', explode(',', env('MODULARITY_SECURITY_MFA_REQUIRED_ROLES', 'admin,marketing-manager,marketing_manager')))), + 'strict' => env('MODULARITY_SECURITY_MFA_STRICT', false), + 'provider' => env('MODULARITY_SECURITY_MFA_PROVIDER', 'email_otp'), // email_otp | google_totp + 'remove_password_login' => (bool) env('MODULARITY_SECURITY_MFA_REMOVE_PASSWORD', true), + 'register_first_time' => (bool) env('MODULARITY_SECURITY_MFA_REGISTER_FIRST_TIME', true), + 'registration_success_route' => env('MODULARITY_SECURITY_MFA_REGISTRATION_SUCCESS_ROUTE', 'admin.register.verification.success'), + 'session_key' => env('MODULARITY_SECURITY_MFA_SESSION_KEY', '2fa:user:id'), + 'flow_session_key' => env('MODULARITY_SECURITY_MFA_FLOW_SESSION_KEY', '2fa:flow:key'), + 'otp_field' => env('MODULARITY_SECURITY_MFA_OTP_FIELD', 'verify-code'), + 'login_page' => env('MODULARITY_SECURITY_MFA_LOGIN_PAGE', 'login_mfa'), + 'challenge_page' => env('MODULARITY_SECURITY_MFA_CHALLENGE_PAGE', 'login_2fa'), + 'challenge_form_route' => env('MODULARITY_SECURITY_MFA_CHALLENGE_FORM_ROUTE', 'admin.login-2fa.form'), + 'throttle' => env('MODULARITY_SECURITY_MFA_THROTTLE', '6,1'), + 'email_otp' => [ + 'length' => (int) env('MODULARITY_SECURITY_MFA_EMAIL_OTP_LENGTH', 6), + 'expire_minutes' => (int) env('MODULARITY_SECURITY_MFA_EMAIL_OTP_EXPIRE_MINUTES', 10), + 'max_attempts' => (int) env('MODULARITY_SECURITY_MFA_EMAIL_OTP_MAX_ATTEMPTS', 5), + 'cache_prefix' => env('MODULARITY_SECURITY_MFA_EMAIL_OTP_CACHE_PREFIX', 'mfa:email-otp'), + ], + ], + + 'throttle' => [ + 'login' => env('MODULARITY_SECURITY_THROTTLE_LOGIN', '8,1'), + 'login_2fa' => env('MODULARITY_SECURITY_THROTTLE_LOGIN_2FA', '6,1'), + 'critical_action' => env('MODULARITY_SECURITY_THROTTLE_CRITICAL', '30,1'), + ], + + 'session' => [ + 'idle_timeout_minutes' => (int) env('MODULARITY_SECURITY_IDLE_TIMEOUT_MINUTES', 60), + 'step_up_ttl_minutes' => (int) env('MODULARITY_SECURITY_STEP_UP_TTL_MINUTES', 15), + ], + + 'critical_field_permissions' => [ + 'robots_index' => 'page_edit', + 'robots_follow' => 'page_edit', + 'canonical_url' => 'page_edit', + 'head_scripts' => 'site_setting_edit', + 'body_scripts' => 'site_setting_edit', + 'redirect_from' => 'redirect_edit', + 'redirect_to' => 'redirect_edit', + 'status_code' => 'redirect_edit', + ], + + 'step_up' => [ + 'enabled' => env('MODULARITY_SECURITY_STEP_UP_ENABLED', false), + // Managed dynamically from SystemUser/Capability route (requires_step_up=true). + 'required_capabilities' => [], + 'provider' => 'email_otp', + 'otp_field' => 'verify-code', + 'page' => 'step_up', + 'challenge_form_route' => 'admin.step-up.form', + 'verify_route' => 'admin.step-up.verify', + 'resend_route' => 'admin.step-up.resend', + 'user_session_key' => 'step-up:user:id', + 'flow_session_key' => 'step-up:flow:key', + 'capability_session_key' => 'step-up:capability:key', + 'pending_request_session_key' => 'step-up:pending:request', + 'return_url_session_key' => 'step-up:return:url', + 'email_otp' => [ + 'length' => 6, + 'expire_minutes' => 10, + 'max_attempts' => 5, + 'cache_prefix' => 'step-up:email-otp', + ], + ], +]; diff --git a/config/merges/tables.php b/config/merges/tables.php index 6e730932e..6e9df4d38 100644 --- a/config/merges/tables.php +++ b/config/merges/tables.php @@ -27,7 +27,23 @@ 'process_histories' => 'um_process_histories', 'assignments' => 'um_assignments', 'user_oauths' => 'um_user_oauths', + 'capabilities' => 'um_capabilities', + 'capability_routes' => 'um_capability_routes', + 'role_capability' => 'um_role_capability', + 'capability_capability_route' => 'um_capability_capability_route', 'email_verification_tokens' => 'um_email_verification_tokens', 'countries' => 'um_countries', 'country_translations' => 'um_country_translations', + + 'cms_pages' => 'um_cms_pages', + 'cms_page_translations' => 'um_cms_page_translations', + 'cms_pages_revisions' => 'um_cms_pages_revisions', + 'cms_redirects' => 'um_cms_redirects', + 'cms_site_settings' => 'um_cms_site_settings', + 'cms_search_indexes' => 'um_cms_search_indexes', + 'cms_page_slugs' => 'um_cms_page_slugs', + 'cms_url_routes' => 'um_cms_url_routes', + 'cms_parent_segment_bindings' => 'um_cms_parent_segment_bindings', + 'cms_sitemaps' => 'um_cms_sitemaps', + 'cms_sitemapables' => 'um_cms_sitemapables', ]; diff --git a/config/merges/traits.php b/config/merges/traits.php index 816f86558..04d67afda 100644 --- a/config/merges/traits.php +++ b/config/merges/traits.php @@ -127,4 +127,34 @@ 'description' => 'Would you like to make this module a singleton?', ], ], + 'addCmr' => [ + 'model' => \Modules\Cms\Entities\Concerns\IsCmr::class, + 'repository' => \Modules\Cms\Repositories\Traits\CmrTrait::class, + 'question' => 'Do you need to add content module route (CMR) feature on this module route?', + 'command_option' => [ + 'shortcut' => null, + 'input_type' => InputOption::VALUE_NONE, + 'description' => 'Do you need to add content module route (CMR) feature on this module route?', + ], + ], + 'addParentSegment' => [ + 'model' => \Modules\Cms\Entities\Concerns\HasParentSegment::class, + 'repository' => \Modules\Cms\Repositories\Traits\ParentSegmentTrait::class, + 'question' => 'Do you need to add parent segment feature on this module route?', + 'command_option' => [ + 'shortcut' => null, + 'input_type' => InputOption::VALUE_NONE, + 'description' => 'Do you need to add parent segment feature on this module route?', + ], + ], + 'addPublishable' => [ + 'model' => \Unusualify\Modularity\Entities\Traits\Publishable::class, + 'repository' => \Unusualify\Modularity\Repositories\Traits\PublishableTrait::class, + 'question' => 'Do you need to add publishable feature on this module route?', + 'command_option' => [ + 'shortcut' => null, + 'input_type' => InputOption::VALUE_NONE, + 'description' => 'Do you need to add publishable feature on this module route?', + ], + ], ]; diff --git a/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php b/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php index c619c5695..c38829e41 100644 --- a/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php +++ b/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php @@ -24,6 +24,8 @@ public function up(): void $table->boolean('published')->default(true); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); + $table->boolean('google_2fa_enabled')->default(false); + $table->string('google_2fa_secret')->nullable(); $table->string('language')->default('en'); $table->string('timezone')->default('Europe/London'); $table->string('phone', 20)->nullable(); diff --git a/docs/.editorconfig b/docs/.editorconfig index 0d1ba54a8..216ea5fb1 100755 --- a/docs/.editorconfig +++ b/docs/.editorconfig @@ -10,6 +10,7 @@ trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false +max_line_length = off [*.{yml,yaml,json}] indent_size = 2 diff --git a/docs/CMS_PUBLIC_STACK.md b/docs/CMS_PUBLIC_STACK.md new file mode 100644 index 000000000..c9eaf6122 --- /dev/null +++ b/docs/CMS_PUBLIC_STACK.md @@ -0,0 +1,127 @@ +# CMS public stack — uçtan uca ve dokunma haritası + +Bu belge, ön yüz CMS URL’lerinin **parent segment + UrlRoute + çözümleyici + SEO/canonical** zincirinde nasıl işlendiğini özetler. Projede yapılan güncel noktaları toparlayıp, “ne nereye dokunuyor” sorusuna dönüş için tek kaynak olarak kullanın. + +--- + +## Kısa özet + +1. **ParentSegment** + Hangi modelin hangi dilde hangi URL önekiyle (ör. `pages`, boş önek ana sayfa) yayında olduğu **kayıt defteri**. Catch-all ve `CmsParentSegmentResolver` burayı okur. + +2. **UrlRoute + CmsUrlRouteRegistry** + Yayınlanan her public sayfa satırı: `locale` + `normalized_path` + `urlable` + `kind`. **Reklam/SEO URL’lerinin tek kaynağı**; admin’de slug/path değişince registry eşitlenir (observer + job’lar). + +3. **Public çözümleme (CmsPublicModelResolver)** + İstek path’ini ve locale’i **visitor path** katmanından alır, `UrlRoute` satırını bulur, modele bağlar. + +4. **Redirect katmanı** + `Redirect` entity + `CmsVisitorRedirectResolver`: sayfa değilse ve kural varsa HTTP yönlendirme. Ziyaretçi redirect’leri `VisitorRedirectMiddleware` ile stack’te. + +5. **SEO + canonical** + View’a giden başlık/açıklama/canonical: `CmsPublicSeo` + `CanonicalUrlResolver(Interface)`. + Config: `cms_routing.canonical_host`, `redirect_to_canonical`, `hide_default_locale_segment`, slugless ayarları. + +--- + +## Katmanlı akış (diyagram) + +Aşağıdaki sıra, tipik bir **GET public sayfa** isteğinde genel olarak geçilen katmanları gösterir (middleware sırası config’e göre ince ayarlanır). + +```mermaid +flowchart TB + subgraph L1["1 — HTTP / rota"] + R1["CmsFrontRouteRegistrar\nGET {path} veya {locale}/{path}\n+ isteğe bağlı Route::domain(host)"] + R2["İmzalı önizleme\nCmsServiceProvider\nGET cms/preview/{module}/{route}/{id}/{locale?}\n→ CmsSignedPublicPreviewController"] + R3["Statik\n/robots.txt, /sitemap.xml"] + end + + subgraph L2["2 — Middleware (cms_routing + cms_features)"] + M1["web"] + M2["FallbackLocaleSluglessCanonicalMiddleware\nslugless /en/... düzeltmesi"] + M3["LaravelLocalizationRoutes\n(locale_param modunda)"] + M4["CanonicalLocaleMiddleware\nopsiyonel canonical yönlendirme"] + M5["VisitorRedirectMiddleware\nsite Redirect kuralları"] + end + + subgraph L3["3 — Path / locale ayrıştırma"] + V1["CmsVisitorRedirectResolver\nresolveLocalePathKeyAndExplicitFlag\nresolveLocaleAndInnerPath"] + V2["CmsFrontPath\npath segment çıkarımı"] + V3["CanonicalUrlResolver\nnormalizePath, registry lookup varyantları"] + end + + subgraph L4["4 — Registry & segment"] + PS["ParentSegment\nhedef model + locale başına önek"] + PSR["CmsParentSegmentResolver\nURL’deki segment → önek doğrulama"] + REG["CmsUrlRouteRegistry\nistenen public path’ler, çakışma, sync"] + UR["UrlRoute\nnormalized_path + locale + kind"] + end + + subgraph L5["5 — Entity çözümleme"] + PM["CmsPublicModelResolver\nfindPublicUrlRouteRow\nimplicit locale kuralları (slugless fallback)"] + GATE["CmsParentSegmentRegistryGate\nmodel izin kontrolü"] + end + + subgraph L6["6 — Sunum & SEO"] + CTRL["CmsController / CmsPublicFrontController\nrenderPublicCmsPresentation"] + SEO["CmsPublicSeo\nbaşlık, description, robots"] + CAN["CanonicalUrlResolver\npublic canonical URL / yönlendirme mantığı"] + SVU["CmsPublicSiteUrl\npanelden mutlak public link host’u"] + end + + L1 --> L2 + L2 --> L3 + L3 --> L4 + L4 --> L5 + L5 --> L6 + + PS --> PSR + REG --> UR + UR --> PM + PM --> GATE + GATE --> CTRL + CTRL --> SEO + CTRL --> CAN +``` + +**Okuma ipuçları** + +- Catch-all `{path}` **imzalı önizleme önekini** (`cms_routing.signed_preview.path_prefix`, varsayılan `cms/preview`) eşleştirmez; böylece önizleme rotası sayfa catch-all’ına takılmaz (`CmsFrontRouteRegistrar::catchAllPathParameterPattern`). +- **Implicit (prefix’siz) URL**’ler yalnızca **slugless fallback locale** (veya slugless kapalıysa CMS default locale) için `UrlRoute` satırıyla eşleşir; başka dilde tek satır varsa prefix’siz istek 404 olur (`CmsPublicModelResolver::resolveUrlRouteWhenLocaleImplicit`). + +--- + +## Ne nereye dokunuyor — hızlı tablo + +| Konu | Ana sınıf / dosya | Config anahtarları (örnek) | +|------|-------------------|----------------------------| +| Public catch-all kaydı | `modules/Cms/Routing/CmsFrontRouteRegistrar.php` | `cms_routing.*`, `cms_features.enabled` | +| Locale’li route grubu | `CmsFrontRouteLocalizationBinding` | `public_front_route_group_mode`, `localization_driver` | +| Domain’e bağlama | `CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain` | `public_front_route_domain`, `public_front_routes_allow_any_host` | +| Ziyaretçi yönlendirme | `CmsVisitorRedirectResolver`, `VisitorRedirectMiddleware` | `visitor_redirects_enabled` | +| UrlRoute satırları | `Entities/UrlRoute.php`, `CmsUrlRouteRegistry` | `tables.cms_url_routes` | +| Parent segment | `Entities/ParentSegment.php`, `CmsParentSegmentResolver` | `cms_parent_segments.*` | +| Sayfa çözümleme | `CmsPublicModelResolver` | `fallback_locale_optional_path_segment`, `public_pages_enabled` | +| Canonical / path norm | `CanonicalUrlResolver`, `CanonicalUrlResolverInterface` | `canonical_host`, `redirect_to_canonical`, `hide_default_locale_segment` | +| Blade SEO | `CmsPublicSeo` | — (çeviri alanları + resolver) | +| Panel permalink host | `CmsPublicSiteUrl` | `public_front_route_domain`, `canonical_host` | +| İmzalı önizleme | `CmsSignedPublicPreviewController`, `CmsSignedPreviewUrlGenerator` | `signed_preview.*` | + +--- + +## Bu çalışmada eklenen / sıkılaştırılan davranışlar (geri dönüş notu) + +1. **Catch-all vs imzalı önizleme** — `{path}` artık `cms/preview/...` (ve isteğe bağlı ek prefix’ler) ile başlamıyor; paylaşılabilir önizleme linki doğru controller’a gider. +2. **Domain politikası** — Public catch-all varsayılan olarak `APP_URL` host’una sıkılaştırılabilir; tam tersi için `public_front_routes_allow_any_host` veya eski `bind_public_routes_to_app_url_host` ters-mantık uyumu. +3. **locale_param + slugless** — Hem `{locale}/{path}` hem prefix’siz `{path}` kayıtları (slugless açıkken). +4. **Implicit locale = yalnızca fallback/default** — Prefix’siz URL yalnızca belirlenen implicit locale’nin `UrlRoute` satırıyla eşleşir; yalnızca TR satırı varken `/tr/...` kullanılmalı. + +--- + +## İlgili birleşik config dosyası + +Çoğu anahtar: `config/merges/cms_routing.php` (uygulamada `modularity.cms_routing.*`). + +--- + +*Son güncelleme: bu belge CMS public stack kapanışı için oluşturuldu; mimari değişince tablo ve diyagram güncellenmelidir.* diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 000000000..66e82f4fa --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,208 @@ +# Modularity Configuration System + +Modularity uses a layered configuration system. Understanding the layers helps when customizing or debugging. + +## Configuration Layers + +### 1. merges (Package Defaults) + +**Location**: `config/merges/*.php` +**Loaded**: At bootstrap (BaseServiceProvider::registerBaseConfigs) +**Key**: `modularity.{filename}` (e.g. `modularity.services`, `modularity.roles`) + +Package defaults that do not depend on the translator. Merged recursively with `array_merge_recursive_preserve()`. + +**Examples**: +- `merges/services.php` → `config('modularity.services')` +- `merges/roles.php` → `config('modularity.roles')` +- `merges/traits.php` → `config('modularity.traits')` + +### 2. defers (Localized Config) + +**Location**: `config/defers/*.php` +**Loaded**: Per request via `LoadLocalizedConfig` middleware (runs in `modularity.core` group) +**Key**: `modularity.{filename}` + +Config that needs the translator (e.g. `__()`, `___()`). Loaded after the translator is available so translation keys resolve correctly. + +**Examples**: +- `defers/navigation.php` → sidebar labels, menu items +- `defers/form_drafts.php` → form field labels +- `defers/widgets.php` → widget titles + +### 3. publishes (App Overrides) + +**Location**: Published to `config/` via `php artisan vendor:publish --tag=modularity-config` +**Loaded**: Standard Laravel config loading + +App-level overrides. Published files take precedence when merged. + +**Common published configs**: +- `config/modularity.php` (base config) +- `config/modules.php` (module paths, namespaces) +- `config/permission.php` +- `config/auth.php` (modularity guard) + +### 4. App Override Path + +**Location**: `base_path('modularity/*.php')` +**Loaded**: By `LoadLocalizedConfig` middleware when files exist + +Optional app-specific config files that override deferred config. Check `LoadLocalizedConfig` for the exact merge order. + +## Base Config + +**File**: `config/config.php` +**Key**: `modularity` (via `$baseKey`) + +Core package settings: app_url, admin paths, theme, enabled features, etc. + +## CMS revisions + +CMS **Pages** use the core revision stack: `HasRevisions` on the model, `PageRevision` rows (`tables.cms_pages_revisions` / `um_cms_pages_revisions`), and repository methods from `RevisionsTrait` (list, restore, approve/reject, preview). There is no separate “revision snapshot” config or UUID snapshot table. + +## CMS signed public preview + +**Config**: `modularity.cms_routing.signed_preview` (`config/merges/cms_routing.php`) + +- `enabled` — `MODULARITY_CMS_SIGNED_PREVIEW_ENABLED` (default true). +- `path_prefix` — URL prefix for the **public** signed route (default `cms/preview`), e.g. `GET /cms/preview/{Module}/{Route}/{id}/{locale?}` (Studly module + submodule, numeric id) with `signature` + `expires` query parameters. +- `ttl_minutes` — `MODULARITY_CMS_SIGNED_PREVIEW_TTL_MINUTES` (minimum 5). +- `throttle_max_attempts` / `throttle_decay_minutes` — per-IP throttle on the signed preview route. + +**Behavior:** `CmsSignedPublicPreviewController` picks the correct `Front\*Controller` subclass for the given module + route, then renders the same Blade as the public catch-all but loads the row **without** `published` / `visible` scopes. Applies to any submodule whose model uses `HasParentSegment` and has a `CmsController` front handler. Response uses `noindex, nofollow`. Visitor redirect middleware skips paths under `path_prefix` so redirect rules do not intercept preview URLs. + +**Panel:** Authenticated `GET …/cms/signed-public-preview/{module}/{route}/{id}?locale=` (Cms module web group; route name `*.signed_public_preview.mint`) returns JSON `{ url, expires_in_minutes }`. The edit form shows **Copy shareable preview link** when `signedPublicPreview` is present (any qualifying submodule, not only Page). + +## CMS publish window schedule (optional) + +Visitor-visible pages already respect `publish_start_date` / `publish_end_date` at **query time** via `published` + `visible` scopes; no cron is required for 404/unpublish behavior. + +**Config**: `modularity.cms_schedule` (`config/merges/cms_schedule.php`) + +- `enabled` — master toggle for the scan job’s internal work. +- `register_with_laravel_schedule` — `MODULARITY_CMS_SCHEDULE_REGISTER`: when **true**, `CmsServiceProvider` registers `ScanCmsPublishWindowBoundariesJob` on Laravel’s scheduler (default **false** so hosts opt in explicitly). +- `frequency` — `everyMinute`, `everyFiveMinutes` (default), or `hourly`. +- `boundary_window_minutes` — look-back window; rows whose `publish_start_date` or `publish_end_date` falls in `(now - window, now]` trigger an event. +- `log_events` — log each boundary to the default log channel. +- `cache_flush_tags` — optional comma-separated list (env `MODULARITY_CMS_SCHEDULE_CACHE_TAGS`); when the store supports tagging, tags are flushed once per scan if any boundary fired. + +**Event:** `CmsPublishWindowBoundaryReached` (`modelClass`, `modelId`, `publish_start` | `publish_end`). Listen in the host app for notifications, CDN purge, or search indexing. + +**Models:** `modularity.cms_schedule.publish_window_models` — list of Eloquent FQCNs (default `Page` only). Comma-separated env `MODULARITY_CMS_PUBLISH_WINDOW_MODELS` overrides. Each table is skipped unless it exists; only columns that exist (`publish_start_date`, `publish_end_date`) are queried. + +**Host registration (manual):** If you prefer not to use `register_with_laravel_schedule`, register the job from the application’s `routes/console.php` (Laravel 11+) or `App\Console\Kernel`: + +```php +use Illuminate\Support\Facades\Schedule; +use Modules\Cms\Jobs\ScanCmsPublishWindowBoundariesJob; + +Schedule::job(new ScanCmsPublishWindowBoundariesJob)->everyFiveMinutes(); +``` + +Ensure the scheduler is running (`php artisan schedule:work` in development, cron `schedule:run` in production). + +## Currency Provider + +**Config**: `modularity.currency_provider` +**Env**: `MODULARITY_CURRENCY_PROVIDER` + +Optional FQCN of a class implementing `CurrencyProviderInterface`. When null, Modularity uses `SystemPricingCurrencyProvider` if the SystemPricing module is present, else `NullCurrencyProvider`. + +## Paths + +**Config**: `modularity.paths` (from merges/paths.php) + +Defines base paths for modules, vendor assets, and published resources. + +## Security Step-Up (Capability Whitelist) + +**Config**: `modularity.security.step_up` (only `enabled` toggle) + +Step-up now has an explicit whitelist: + +- `enabled` (bool): global toggle. +- `required_capabilities` is no longer managed from config. + +Default: + +```php +'step_up' => [ + 'enabled' => false, + 'required_capabilities' => [], // runtime-managed +], +``` + +Behavior: + +- `required_capabilities` is built dynamically from `SystemUser > Capability` records where `requires_step_up = true`. +- If route uses `modularity.security.step_up:`, that `` must be active and marked as `requires_step_up`. +- User must also own that capability via Capability record role assignments. + +How to fill (UI): + +1. Open `System > User Management > Capabilities`. +2. Create capability keys (examples: `promotion.execute`, `scripts.manage`, `redirect.manage`). +3. Assign allowed roles for each capability (stored via `um_role_capability` pivot). +4. Toggle `Require Step-Up` for capabilities that need re-auth. +5. (Optional) Enable `Strict Route Binding` and bind route names under `Capability Routes`. +6. Keep `published` active for capabilities that should be effective. + +Route usage: + +- Use middleware with capability argument, e.g. `modularity.security.step_up:promotion.execute`. + +Route binding model: + +- Capability routes are stored in `um_capability_routes`. +- If `strict_route_binding = false`, capability match alone is enough. +- If `strict_route_binding = true`, current Laravel route name must be in active bound routes for that capability. + +Route discovery endpoint: + +- `GET {admin-prefix}/system/system-user/capabilities/discover-routes` (web-auth protected) +- Query params: + - `search` or `q`: filter by route name/uri + - `only_named` (default: `true`) + - `itemsPerPage` or `limit` + - `page` + +Suggested async input binding: + +```php +[ + 'type' => 'select-scroll', + 'componentType' => 'v-autocomplete', + 'name' => 'route_name', + 'label' => 'Route Name', + 'endpoint' => 'admin.system.system_user.capabilities.discover_routes', + 'itemTitle' => 'name_with_uri', + 'itemValue' => 'name', + 'itemsPerPage' => 100, + 'page' => 1, + 'searchKeys' => ['name', 'uri'], +] +``` + +`capability_route` stores only the Laravel route name. `uri` and `method` stay discoverable metadata and are not persisted. + +Capabilities bind to routes through a pivot table. In practice: + +- Manage the route registry from `CapabilityRoute` +- Assign one or many registered routes from the `Capability` form via the `routes` field + +## CMS public URL + robots.txt + +**Routing (`modularity.cms_routing`)** — `config/merges/cms_routing.php`: `front_route_prefix` (public CMS path segment, default `cms`), `default_locale`, `canonical_host`, `redirect_to_canonical`, visitor redirect toggles. Front catch-all: `modules/Cms/Routes/front.php` → `PublicPageController`. **Admin:** `admin.slug_nested_path_warnings`, `admin.slug_public_path_preview` (slug alanında segment uyarıları + canlı path ipucu; hydrate `cmsPublicPathPreview`); `admin.slug_max_path_segments` (`MODULARITY_CMS_ADMIN_SLUG_MAX_PATH_SEGMENTS`, default sınırsız) — üst sınırı aşan çok segmentli slug girişi `CmsSlugInputValidationService` ile reddedilir (ör. `1` = tek segment). **API:** authenticated `GET …/cms/routing-meta` (Modularity API prefix + `cms/routing-meta`; `CmsRoutingMetaController`) — prefix + locale defaults + `admin.slug_*` özeti. **Panel `Slug.vue`:** slug doğrulama POST’u `useStepUpAwareJsonPost` (HTTP 428 + step-up); aynı meta URL’den istemci tarafı segment limiti kuralı (backend ile uyumlu). + +**SEO (`modularity.cms_seo`)** — `config/merges/cms_seo.php`: `canonical.*` (lowercase path, trailing slash); `robots.route_enabled` (`MODULARITY_CMS_ROBOTS_TXT_ROUTE_ENABLED`) registers **GET `/robots.txt`** (`cms.robots_txt`) with `web` middleware. **Body resolution:** when `robots.use_site_settings` is true (default, `MODULARITY_CMS_SEO_ROBOTS_USE_SITE_SETTINGS`), the first source is the `um_cms_site_settings` row keyed by `robots.site_setting` (default group `seo`, key `global_robots_txt`, locale `*`); otherwise (or when disabled) use `robots.global_robots_txt` (`MODULARITY_CMS_SEO_GLOBAL_ROBOTS_TXT`). Empty/whitespace falls back to `User-agent: *\nAllow: /`. **Panel:** Inertia **Site SEO** (`admin.system.cms.siteSeo.*`) edits the DB-backed body. **`admin.publish_soft_warnings`** — publish sonrası SEO boş alan uyarıları (`CmsAdminWarnings`). **`admin.publish_schedule_warnings`** (`MODULARITY_CMS_ADMIN_PUBLISH_SCHEDULE_WARNINGS`) — yayınlanmış sayfa kaydedildiğinde, isteğe bağlı `publish_start_date` / `publish_end_date` penceresi dışındaysa (ziyaretçi tarafında zaten `HasScopes::scopeVisible` ile görünmez) panelde yumuşak uyarı. Disable the robots route if the host app serves its own `robots.txt`. + +**Site-wide keys:** Generic key-value rows remain available under the CMS `SiteSetting` CRUD (`site_setting` route); the Site SEO screen targets the global robots body specifically. + +**Sitemap (`modularity.cms_sitemap`)** — `config/merges/cms_sitemap.php`: `route_enabled` registers **GET `/sitemap.xml`** (`cms.sitemap`, `web` middleware) from the committed cache (`CmsSitemapCacheService`); `cache_key`; optional `build_on_cache_miss` (off by default); `default_sitemap_id` (matches seeded row in `um_cms_sitemaps`); `defaults.changefreq` / `defaults.priority` when a row in `um_cms_sitemapables` has no override; `lastmod.source` = base model `updated_at` (see `CmsSitemapBuildService` PHPDoc). **Panel:** Inertia **`Sitemap`** (`vue/src/js/Pages/Sitemap.vue`) — `GET …/sitemap` (`sitemap.tool`), `POST …/sitemap/dry-run` (`sitemap.dryRun.web`), `POST …/sitemap/commit` (`sitemap.commit.web`); `SitemapRequest` + `SitemapRepository` (`getPanelDryRunPayload` / `commitSitemapToLiveCache`); `SitemapController` (shell) like other submodule controllers; when step-up is enabled, **commit** uses `panel.step_up_ability.commit` (default `sitemap.commit`). Sidebar: `config/defers/navigation.php` → `_cms_sitemap` (`admin.system.cms.sitemap.tool`). **Module config** (`modules/Cms/Config/config.php` → `sitemap`) documents the tool. **Build:** `CmsSitemapBuildService` + **commit:** `php artisan cms:sitemap:rebuild` or `Modules\Cms\Jobs\RebuildCmsSitemapJob`. + +**Parent segment (modül ortak URL öneki):** `modularity.cms_parent_segments` (`config/merges/cms_parent_segments.php`, `MODULARITY_CMS_PARENT_SEGMENTS_ENABLED`). Tablolar: `tables.cms_parent_segments`, `tables.cms_parent_segment_targets` — katalog + `target_class` (FQCN, `urlable_id` yok) + `locale` (boş = tüm diller). `CmsParentSegmentResolver` tam public yolu `prefix + slug_leaf` olarak üretir; `um_cms_url_routes.normalized_path` **tam yol** olarak kalır (çözümleyici/çakışma için). **Panel:** `GET …/parent-segments` (`ParentSegmentsToolController`, Inertia `ParentSegments.vue`); mutasyonlar oturumlu `web` rotaları: `POST/PATCH/DELETE …/parent-segments` (`Cms` modülü web group). **API:** `GET/POST/PATCH/DELETE …/cms/parent-segments` (`ParentSegmentController`, `api.php`). `GET …/cms/routing-meta` → `parent_segment_prefixes` (ör. `Page` sınıfı için locale → prefix haritası). Sidebar: `config/defers/navigation.php` → `_cms_parent_segments` (`admin.system.cms.parentSegments.tool`). + +**Nested public paths:** `CmsPublicPathHierarchy` + `CmsUrlRouteRegistry::nestedPublicPagePathWarnings` — üst/alt segment örtüşmesi **uyarı** (slug validate API + `Slug.vue`). Aynı `locale + path` için başka sayfa/redirect kaydı: `isPathClaimedByOther` → slug doğrulama **hata** (kayıt öncesi). + +**Promotion (`modularity.cms_promotion`)** — `config/merges/cms_promotion.php`: `enabled`, `scope.*`, `approval.*`. **Dry-run / execute:** `CmsPromotionService` — tek ortamda tablo özetleri; isteğe bağlı **ikinci DB connection** (`compare.connection`) ile salt okunur **sayı farkları** (`diff.comparison.count_delta`); tam veri kopyası yok. `compare.allowed_connections` ile hedef connection beyaz listesi. **Execute:** Modularity cache flush; isteğe bağlı `execute.flush_laravel_cache`; `CmsPromotionExecuted` event; `CmsPromotionScopeApplierInterface` (varsayılan no-op, binding ile genişletilir); `audit.activity_log` + `audit.log_channel`. **Job:** `PromoteCmsReleaseJob` — payload’da `user_id` ile audit. Gerçek ortamlar arası veri aktarımı ayrı pipeline / export-import ile yapılır. **Panel POST + step-up:** session web rotalarında güvenlik açıksa `modularity.security.step_up` — promotion için capability ipucu `promotion.execute`, Site SEO kaydı için `site_seo.edit`; Vue tarafında `useStepUpAwareJsonPost` 428 + modal ile `useForm` ile aynı UX’i hedefler. diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 000000000..55795e6b8 --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,49 @@ +# Module System + +## Module vs Route Activation + +Modularity has two activation concepts: + +1. **Module enable/disable**: Via Nwidart's activator (e.g. `modules_statuses.json` or database). Controls whether a module is loaded at all. + +2. **Route enable/disable**: Via `ModuleActivator` and per-module `routes_statuses.json`. Controls which routes within an enabled module are registered. + +A module can be enabled but have specific routes disabled (e.g. hide the create route). + +## Module Discovery + +Modules are scanned from: +- `config('modules.paths.modules')` (default: `modules/`) +- `config('modules.scan.paths')` when scan is enabled + +Each module directory must contain `module.json`. + +## Module Provider Registration + +**Convention**: `ModuleServiceProvider` loads `*ServiceProvider.php` from each module's `Providers/` folder. No need to list providers in `module.json`. + +**Optional**: The `providers` array in `module.json` can list additional provider classes for explicit registration. These are merged with the convention-based discovery. + +## Module Structure + +``` +modules/MyModule/ +├── module.json +├── Config/ +├── Database/Migrations/ +├── Entities/ +├── Http/Controllers/ +├── Providers/ # *ServiceProvider.php auto-loaded +├── Repositories/ +├── Routes/ +│ ├── web.php +│ ├── front.php +│ ├── api.php +└── Resources/ + ├── lang/ + └── views/ +``` + +## Route Status + +Use `php artisan modularity:route:enable` and `modularity:route:disable` to toggle routes. Status is stored in `modules/{ModuleName}/routes_statuses.json`. diff --git a/docs/PINIA_MIGRATION.md b/docs/PINIA_MIGRATION.md new file mode 100644 index 000000000..10637d4ab --- /dev/null +++ b/docs/PINIA_MIGRATION.md @@ -0,0 +1,43 @@ +# Pinia Migration Path + +Modularity currently uses Vuex 4. For new projects, Pinia is the recommended state management library for Vue 3. + +## Current State + +- Vuex 4 with modules: config, user, alert, language, mediaLibrary, browser, cache, ambient +- Mutations via constants (CONFIG, USER, ALERT, etc.) +- `useStore()` in composables + +## Migration Strategy + +1. **Short-term**: Keep Vuex. No breaking changes. +2. **Medium-term**: Add Pinia alongside Vuex. Create `store/pinia/` with equivalent modules. +3. **Long-term**: Migrate composables to use Pinia; deprecate Vuex. + +## Pinia Module Equivalents + +| Vuex Module | Pinia Store | +|-------------|-------------| +| config | useConfigStore() | +| user | useUserStore() | +| alert | useAlertStore() | +| language | useLanguageStore() | +| mediaLibrary | useMediaLibraryStore() | + +## Wrapper Pattern + +For easier migration, use `storeToRefs`-style access in composables: + +```js +// Current (Vuex) +const store = useStore() +store.state.config.isInertia + +// Future (Pinia) +const configStore = useConfigStore() +const { isInertia } = storeToRefs(configStore) +``` + +## Target Version + +Pinia migration is planned for Modularity v4.x. No timeline set. diff --git a/docs/SECURITY_SERVICES_OVERVIEW.md b/docs/SECURITY_SERVICES_OVERVIEW.md new file mode 100644 index 000000000..44c0362fa --- /dev/null +++ b/docs/SECURITY_SERVICES_OVERVIEW.md @@ -0,0 +1,97 @@ +# Middleware ve servisler — özet + +Bu dosya, Modularity / CMS tarafında öne çıkan **middleware** ve **servislerin** ne işe yaradığını tek yerde toplar. (Yönetim paneli kullanıcıları ve veri modeli odaklı; ziyaretçi tarafında henüz tamamlanmamış parçalar aşağıda not edilir.) + +--- + +## 1. Güvenlik (core — `src/`) + +`SecurityServiceProvider` yalnızca `modularityConfig('security.enabled')` açıkken devreye girer. Varsayılan panel route yığınına üç alias eklenir: + +| Alias | Sınıf | Amaç | +|--------|--------|------| +| `modularity.security.session` | `SessionSecurityMiddleware` | Oturum **idle timeout** (yapılandırılabilir dakika). Süre aşılırsa çıkış + oturum invalidate; JSON isteklerinde 401, formda login’e yönlendirme. Her istekte `security_last_seen_at` güncellenir. | +| `modularity.security.require_mfa` | `RequireMfaMiddleware` | Kullanıcının rolü **MFA gerektiriyorsa** ve MFA etkin değilse isteği keser (JSON’da 403; web’de logout + login formu). | +| `modularity.security.step_up` | `StepUpMiddleware` | `security.step_up.enabled` açıksa, route **capability** ile eşleşiyorsa ve kullanıcı son doğrulamayı TTL içinde yapmamışsa `StepUpService` ile akışı keser (ek doğrulama). TTL session’da `security_step_up_verified_at` ile tutulur. | + +**İlişkili servisler (özet):** `SecurityService` (MFA / step-up eşleştirme), `StepUpService` (kesinti / doğrulama UI’si). Detay: capability tabanlı route eşlemesi, cache vb. `HANDOFF.md` ve güvenlik dokümantasyonu. + +--- + +## 2. CMS modülü — URL ve yönlendirme verisi (`modules/Cms/`) + +### 2.1 `CanonicalUrlResolver` (+ arayüz `CanonicalUrlResolverInterface`) + +- **Ne işe yarar:** Gelen path’i normalize eder (slash, küçük harf, trailing slash vb. — `cms_seo.canonical.*` config). +- **`resolve()`:** Host + path + locale ile **kanonik URL** üretir; `cms_routing.redirect_to_canonical` ile “gelen URL kanonik değilse 301 ile düzelt” mantığını hesaplar. +- **Kullanım yeri:** `CanonicalLocaleMiddleware`, redirect doğrulama, `CmsUrlRouteRegistry` path karşılaştırmaları. + +### 2.2 `RedirectValidationService` (+ `RedirectValidationServiceInterface`) + +- **Ne işe yarar:** Panelden kaydedilecek **site yönlendirme kuralları** (`from_path` → `to_path`) için sunucu tarafı kontrol. +- **Kontroller (özet):** Aynı path’e işaret etme, aktif sayfa path’i ile çakışma (locale bazlı `active_paths`), mevcut kurallarla **döngü**, isteğe bağlı **cross-locale uyarısı** (bloklamaz, `warnings` döner). +- **Kullanım yeri:** `RedirectController` (store/update); hatalar validation exception, uyarılar JSON’a veya session flash + Inertia `flash` ile taşınır. + +### 2.3 `CmsUrlRouteRegistry` (+ core `PublicUrlRegistryContract`) + +- **Ne işe yarar:** `UrlRoute` tablosunu **Page** public path’leri ve **Redirect** kaynak path’leri ile senkron tutar (çakışma önleme / tek kaynak registry). Uygular: `Unusualify\Modularity\Contracts\PublicUrlRegistryContract` (container’da bu arayüze `CmsUrlRouteRegistry` bağlanır). +- **Ne zaman:** `Modules\Cms\Repositories\Traits\UrlRouteRegistrySyncTrait` → core `PublicUrlRegistrySyncDispatchTrait` ile sınıf bazlı handler haritası; repository `afterSave` / `afterDelete` / `afterRestore`. +- **Slug doğrulama (panel):** `ExtendsSlugValidationWithPublicUrlRegistry` (`src/Services/Concerns/`) — `CmsSlugInputValidationService` içinde kullanılır; başka modüller aynı trait + kendi `PublicUrlRegistryContract` implementasyonu ile tekrar kullanabilir. +- **Not:** Bu **veri senkronu**dur; ziyaretçi HTTP isteğinde otomatik 302 üretmek için ayrıca bir middleware/route bağlanması gerekir (aşağıda). + +### 2.4 `CanonicalLocaleMiddleware` + +- **Ne işe yarar:** `cms_routing.redirect_to_canonical` açıksa, isteğin path’i kanonik değilse **301** ile kanonik adrese yönlendirir (`CanonicalUrlResolver::resolve`). +- **Redirect CRUD ile ilişki:** Doğrudan `um_cms_redirects` satırlarını okumaz; **locale/host canonical** hattıdır. + +### 2.5 `CmsVisitorRedirectResolver` + `VisitorRedirectMiddleware` + +- **Ne işe yarar:** Panelde kayıtlı **site yönlendirmelerini** (`Redirect` tablosu) gerçek **HTTP isteğine** uygular: eşleşen path için `Location` + yapılandırılan status code (301/302/…). +- **Sıra:** `front.php` içinde `web` → (isteğe bağlı) `CanonicalLocaleMiddleware` → **`VisitorRedirectMiddleware`**. +- **İç path (`CmsFrontPath`):** Modülün public route öneki (ör. `/cms/…`, `cms_routing.front_route_prefix` ile uyumlu) istekten çıkarılır; böylece kayıtlı `UrlRoute` satırları (önek **olmadan**) ile eşleşir. +- **Eşleştirme:** İç path normalize edilir; URL’de locale prefix varsa ayrıştırılır (`/tr/foo` → locale `tr`, iç path `/foo`). Önce **`UrlRoute`** (`kind = redirect_source`) ile indeksli arama; tablo yoksa veya satır yoksa **`Redirect`** modelinde locale + normalize `from_path` ile tarama. +- **Öncelik:** Aynı `locale` + path için **`UrlRoute` `page_public`** (yayın sayfası) varsa yönlendirme **uygulanmaz** (sayfa kazanır). +- **Hariç:** `modularity.admin_app_path` altı (panel), `system_prefix`, `cms_routing.visitor_redirect_exclude_prefixes` (varsayılan: `api`, `sanctum`, `livewire`). +- **Config:** `cms_routing.visitor_redirects_enabled` (varsayılan `true`). + +### 2.6 CMS `front.php` — public sayfa (catch-all) + +- **Ne zaman:** `cms_routing.public_pages_enabled` açıkken (varsayılan `true`). +- **Rota:** Modül URL öneki altında `GET /{path?}` (`path` = `.*`), isim: `…cms.page` (`curtModuleRouteNamePrefix` ile). +- **`CmsPublicPageResolver`:** `CmsFrontPath::innerNormalizedPath` → `CmsVisitorRedirectResolver::resolveLocaleAndInnerPath` (locale segmenti yoksa *implicit* mod; uzun locale kodları önce eşlenir, örn. `pt-BR` / `pt`) → `UrlRoute` (`page_public`) → **yayınlanmış ve görünür** `Page`. URL’de locale yokken yalnızca varsayılan locale ile eşleşmezse, aynı path için tek kayıt (veya çokluysa `default_locale` öncelikli) ile devam edilir. +- **View:** `cms::page.custom` (public) — `CmsPublicSeo` ile ``, meta description, `<link rel="canonical">`, `meta name="robots"` (çeviri `canonical_url`, `robots_index` / `robots_follow`); yoksa `CanonicalUrlResolver` ile kanonik URL. +- **Config:** `cms_routing.front_route_prefix` (env: `MODULARITY_CMS_FRONT_ROUTE_PREFIX`, varsayılan `cms`) — `UrlRoute` kayıtları bu segmenti **içermez**; ziyaretçi URL’si `/cms/tr/...` olsa bile iç eşleştirme `/tr/...` üzerinden yapılır. + +--- + +## 3. Inertia — paylaşılan flash (`src/Http/Middleware/HandleInertiaRequests.php`) + +- **Genel:** Tüm Inertia sayfalarına `auth`, `flash`, `config`, vb. paylaşılır. +- **Uyarılar (stack):** `flash.warnings` — session anahtarı `modularity.flash_warnings` (`Unusualify\Modularity\Support\ModularityFlashWarnings::SESSION_KEY`, tek seferlik `pull`). Birden fazla kaynak aynı istekte `ModularityFlashWarnings::merge()` ile ekleyebilir; Inertia sonraki yüklemede `MainLayout` içinde sırayla toast gösterir. AJAX yanıtlarında aynı anlam `response.data.warnings` dizisiyle taşınır. + +--- + +## 4. Özet tablo: kim, nerede? + +| Bileşen | Kimin isteği | Ana sonuç | +|---------|----------------|-----------| +| Session security middleware | Giriş yapmış panel kullanıcısı | Idle’da çıkış | +| Require MFA middleware | Giriş yapmış kullanıcı | MFA zorunluluğu | +| Step-up middleware | Giriş yapmış kullanıcı | Hassas route’da ek doğrulama | +| Canonical locale middleware | **Ziyaretçi / public** (front stack’e eklendiğinde) | Kanonik URL’e 301 | +| Visitor redirect middleware | **Ziyaretçi / public** | CMS `Redirect` kurallarına göre 301/302… | +| `CmsPublicPageResolver` + `PublicPageController` | **Ziyaretçi / public** | Catch-all ile yayın sayfası (Blade) | +| Redirect validation service | Panel formu (POST/PUT) | Kural geçerli mi + uyarılar | +| CMS URL route registry | Arka plan (save/delete) | `UrlRoute` tablosu güncel | +| Inertia flash (`flash.warnings`) | Sonraki sayfa yükü (editör) | Uyarı toast (stack) | + +--- + +## 5. Bilinçli boşluklar (şu anki mimari) + +- **Public sayfa şablonu:** Minimal Blade; tema, layout birleştirme ve gelişmiş SEO (Open Graph, yapısal veri) sonraki adımlar. +- **Canonical middleware** ile **CRUD redirect kuralları** farklı problemlerdir; ikisi birbirinin yerine geçmez. + +--- + +*Son güncelleme: 2026-04 — Modularity paketi (`packages/modularous`).* diff --git a/docs/SITEMAP_PLAN.md b/docs/SITEMAP_PLAN.md new file mode 100644 index 000000000..5663ef388 --- /dev/null +++ b/docs/SITEMAP_PLAN.md @@ -0,0 +1,127 @@ +# Sitemap sistemi — gereksinimler ve uygulama planı (taslak) + +> **Amaç:** Indexlenebilir sayfaların keşfi için otomatik sitemap, `include/exclude`, `lastmod`, `hreflang`, çok dilli sinyaller. + +> **Not (HasPosition):** [`HasPosition`](src/Entities/Traits/HasPosition.php) yalnızca **liste `position` sıralaması** içindir; sitemap `priority` (0.0–1.0) ve `changefreq` alanları **ayrı** tutulmalı (morph pivot veya sitemap item tablosu). Karıştırma. + +--- + +## 1. Mevcut altyapı (referans) + +- Çeviri tabanlı **`sitemap_include`**: [`TranslatableMetadata`](src/Support/TranslatableMetadata.php) + [`HasTranslatableMetadata`](src/Entities/Traits/HasTranslatableMetadata.php). +- Public URL: `UrlRoute` + `CmsParentSegmentResolver` + `CmsPublicSiteUrl` / `CmsFrontPath` ile hizalı tam URL üretimi. +- Public görünürlük: `published` + `scopeVisible` ([`HasScopes`](src/Entities/Traits/Core/HasScopes.php)), [`CmsPublicModelResolver`](modules/Cms/Services/CmsPublicModelResolver.php). +- **Merkezi rota defteri:** [`UrlRoute`](modules/Cms/Entities/UrlRoute.php) (`kind`: `KIND_PAGE_PUBLIC` vs. `KIND_REDIRECT_SOURCE`), her satır `locale` + `normalized_path` + morph `urlable`. Model kaydında/restore/silme sonrası [`CmsUrlRouteRegistry`](modules/Cms/Services/CmsUrlRouteRegistry.php) + [`UrlRouteRegistrySyncTrait`](modules/Cms/Repositories/Traits/UrlRouteRegistrySyncTrait.php) ile tablo güncellenir; public catch-all çözümü de [`CmsPublicModelResolver`](modules/Cms/Services/CmsPublicModelResolver.php) üzerinde aynı tabloyu okur. [`CmsFrontRouteRegistrar`](modules/Cms/Routing/CmsFrontRouteRegistrar.php) yalnızca **tek catch-all** kaydı ve controller çözümünü açar; path envanteri veritabanındadır. [`CanonicalUrlResolver`](modules/Cms/Services/CanonicalUrlResolver.php) `cms_routing` ile canonical host/segment kuralını üretir (hreflang / alternates ile tutarlılık için referans). + +### 1.1 Sitemap “keşfi”: her alt modülü taramak zorunlu değil + +- **Aday URL listesi** için birincil sorgu yüzeyi: `UrlRoute` satırları, filtre: `kind = page_public` (ve gerekiyorsa `locale` / site kapsamı). `with('urlable')` eager load; filtre: model üzerinde `published`, `scopeVisible`, ilgili çeviride `sitemap_include` (bkz. `TranslatableMetadata`). +- **Neden ayrı submodule döngüsü gerekmez:** Aynı tablo, hangi `Studly` entity sınıfı olursa olsun, trait ile senkronize edilen public sayfa yollarını tutar; sitemap jeneratörü **morph `urlable`** üzerinden tip bağımsız ilerler. Yalnızca *hiç* `UrlRoute`’a yazılmayan bir public sayfa tipi varsa (sync dışı) o tip için ayrı keşif veya sync genişletmesi gerekir. +- **Redirect satırları** (`KIND_REDIRECT_SOURCE`): indexlenebilir “sayfa” sitemap’inden hariç; yalnızca ürün kararına göre ayrı politika. +- **Mutlak URL:** mevcut `CmsFrontPath` + `CmsPublicSiteUrl` (ve gerekirse `CanonicalUrlResolver` çıktısı) ile; `UrlRoute` tek başına host içermez, yol + locale taşır. + +--- + +## 2. Hedef davranış + +| Konu | İstenen | +|------|--------| +| **Üretim** | Sitemap **generator** + **cache** + **rebuild** (queue job) | +| **hreflang** | Locale eşlemesiyle otomatik (mevcut `CmsLocalizationContract` / path segment locale’leri) | +| **Dahil edilecekler** | `HasParentSegment` kullanan modüllerden: aktif, **public**, **published** + `visible` kapsamında, **bozulmamış** yerelleştirilmiş URL’ler | +| **Dahil / hariç (locale)** | [`HasTranslatableMetadata`](src/Entities/Traits/HasTranslatableMetadata.php) + [`TranslatableMetadata::sitemap_include`](src/Support/TranslatableMetadata.php) — ayrı **`IsSitemaping` trait’i oluşturma**; mevcut çeviri alanı sitemap ilişkisi ve panel switch’i için yeterli kabul edilir. | +| **Alt modül** | Yeni `Cms` → **Sitemap** (veya eşdeğer) alt modülü: sitemap’lenebilir kayıtları toplar / yönetir | + +--- + +## 3. “Kayıt yokken de URL varmış gibi” + +- **Yol (path) keşfi** normalde `UrlRoute`’dadır (§1.1). “Sanal” vurgusu burada özellikle **sitemap morph pivot** satırı yokken `changefreq` / `priority` için şema default’larının uygulanması içindir; public path zaten `UrlRoute`’da yoksa o kayıt sitemap’e giremez. +- **Generate** aşamasında, morph pivot’da satır olmasa bile, kaynak model **sitemap kriterlerini** sağlıyorsa (published + visible + `sitemap_include` vb.) URL **UrlRoute + public URL kurallarıyla** türetilip sitemap listesine **sanal** (computed) eklenebilmeli. +- İsteğe bağlı **sync**: `sitemap_include` = true + published + visible kriterini sağlayan `urlable` kayıtlar için pivot satırlarını toplu oluşturma/güncelleme (priority/changefreq persist) — ayrı entity trait şartı yok. + +--- + +## 4. Düzenlenebilir alanlar (pivot / sitemap item) + +- **Düzenlenebilir:** `changefreq`, `priority` (XML sitemap anlamında). +- **Düzenlenmez (kaynak):** `lastmod` → ilişkili modelin **`updated_at`** (gerekirse translation güncellemesi için net kural: ana model mı, translation mı — tek kural seçilir). + +--- + +## 5. Dry-run ve cache (commit öncesi) + +- Sistem, yeni sitemap **commit** edilene kadar **her zaman önceki commit’lenmiş** sitemap’i / önbelleği servis eder. +- **Dry-run** üretim sonucu canlı yanıta yazılmaz; yalnızca önizleme/validasyon. +- **Commit** sonrası: servis cache’i **atomic** veya “swap” ile günceller. +- **Önbellek:** `Cache::rememberForever` (veya eşdeğer kalıcı store) + versiyon/etag anahtarı; commit’te yeni veri bu cache’e yazılır. (Fallback: dosya + `public/sitemap.xml` kopyası — ürün kararı.) + +--- + +## 6. Veri modeli: morph pivot + +- **Sitemap** (veya `sitemap_runs` / `sitemap_revisions`) ile **urlable** (`morph`) arasında pivot: + - `sitemapable_type`, `sitemapable_id` (veya tersi: entry → morph hedef) + - `changefreq`, `priority` (nullable → default şema) + - İsteğe bağlı: `sitemap_id` / `build_id` sürümleme +- Hedef: tek yerden tüm sitemap’e giren modelleri ilişkilendirmek ve override alanlarını saklamak. + +--- + +## 7. Hreflang + +- Aynı kaynak için tüm yayımlı locale’lerde `UrlRoute` + public path ile mutlak URL listesi; `<xhtml:link rel="alternate" hreflang="...">` üretimi. +- Locale listesi: `CmsLocalizationContract::pathSegmentLocales()` / `supportedLocalesMeta` ile uyumlu. + +--- + +## 8. Uygulama maddeleri (sıra önerisi) + +1. **Şema:** migration: `sitemaps` (veya tek satırlık “current”), `sitemapables` morph pivot (changefreq, priority). +2. **Dahil etme kuralı (trait yok):** Jeneratör sorgu kapsamı: `UrlRoute` + `urlable` + ilgili çeviride `sitemap_include` ([`TranslatableMetadata`](src/Support/TranslatableMetadata.php) / [`HasTranslatableMetadata`](src/Entities/Traits/HasTranslatableMetadata.php)) + `published` + `scopeVisible`. Repository’de ayrı `SitemapingTrait` veya modelde `IsSitemaping` **tanımlanmaz**; gerekirse sadece mevcut [`TranslatableMetadataTrait`](src/Repositories/Traits/TranslatableMetadataTrait.php) (form) ile hizalanır. +3. **Servis:** `SitemapBuildService` — keşif: önce [`UrlRoute`](modules/Cms/Entities/UrlRoute.php) (`kind = page_public`) + `urlable` (bkz. §1.1); input: dry-run bool; output: XML veya dizi; **commit** ayrı metot. +4. **Cache servisi:** `SitemapCacheService` — `rememberForever` + commit’te swap; 404/503 yok, eski sitemap canlı kalsın. +5. **Job:** `RebuildSitemapJob` (queue) — ağır toplu üretim. +6. **Route:** `GET /sitemap.xml` (veya index + parçalar) — cache’ten servis; dry-run sadece admin. +7. **Cms Sitemap modülü:** panel’de dry-run sonuç, commit, isteğe bağlı “sync pivot” toplu işlem. +8. **Testler:** dry-run, commit swap, hreflang; ParentSegment’li URL birimi. + +--- + +## 9. Bağımlılıklar + +- Mevcut `UrlRoute` ve public URL bütünlüğü; kırık URL’lerin listelenmemesi (mevcut resolver ile uyum). +- `TranslatableMetadata::sitemap_include` false olan locale satırları hariç. + +--- + +## 10. Sonraki agent — uygulama todo listesi (handoff) + +**Önce oku:** §1–§9. **Sabit kararlar:** (1) Keşif **submodule döngüsü değil**, [`UrlRoute`](modules/Cms/Entities/UrlRoute.php) (`kind = page_public`) + `urlable` (§1.1). (2) **Yeni `IsSitemaping` / `SitemapingTrait` yok**; locale dahil/hariç = [`TranslatableMetadata::sitemap_include`](src/Support/TranslatableMetadata.php) + [`HasTranslatableMetadata`](src/Entities/Traits/HasTranslatableMetadata.php) (§2, §8.2). (3) [`HasPosition`](src/Entities/Traits/HasPosition.php) ≠ XML `priority` (üst not). + +| # | Görev | Notlar | +|---|--------|--------| +| 1 | Migration: `sitemaps` (veya tek “current” revision) + `sitemapables` morph pivot (`changefreq`, `priority` nullable) | §6 | +| 2 | `lastmod` kuralını kod + dokümanda netleştir (ana model `updated_at` vs translation — tek kural) | §4 | +| 3 | `SitemapBuildService` | Sorgu: `UrlRoute` + `with('urlable')`; filtre: `published`, `scopeVisible`, çeviride `sitemap_include`; `KIND_REDIRECT_SOURCE` hariç; mutlak URL: `CmsFrontPath` + `CmsPublicSiteUrl` / `cms_routing` | §1.1, §3 | +| 4 | Hreflang / `xhtml:link` alternates | Aynı `urlable` için locale başına `UrlRoute` satırları; [`CmsLocalizationContract`](modules/Cms/Contracts/CmsLocalizationContract.php) ile uyum | §7 | +| 5 | `SitemapCacheService` | `Cache::rememberForever` (veya eşdeğer) + **commit** ile atomic/swap; canlı sitemap boş/503 olmamalı | §5 | +| 6 | `RebuildSitemapJob` (queue) | §8 | +| 7 | Public route `GET /sitemap.xml` (gerekirse index + parçalar) | Önbellekten servis; dry-run yalnız admin/internal | §8 | +| 8 | Cms **Sitemap** alt modülü (panel) | Dry-run sonuç, commit, isteğe bağlı pivot “sync” toplu işlem | §2, §8 | +| 9 | Testler | Dry-run vs commit, cache swap, hreflang, en az bir ParentSegment’li URL birimi | §8 | +| 10 | Ship sonrası | [`HANDOFF.md`](../HANDOFF.md) Faz 7 satırı + ilgili changelog güncelle | + +**İlgili modül dosyaları (başlangıç):** `modules/Cms/Services/CanonicalUrlResolver.php`, `CmsUrlRouteRegistry.php`, `CmsPublicModelResolver.php`, `Routing/CmsFrontRouteRegistrar.php` — jeneratör aynı URL semantiğini korumalı. + +### 10.1 Uygulama (paket — 2026-04-02) + +- **Servis sınıf adı:** `CmsSitemapBuildService` (plan maddesindeki `SitemapBuildService` ile aynı rol). +- **Şema:** `2026_04_02_000001_create_cms_sitemap_tables.php` → `um_cms_sitemaps` (id=1 `default` seed) + `um_cms_sitemapables` (morph + `changefreq` / `priority`). +- **Cache / route / job / artisan:** `CmsSitemapCacheService` (`Cache::forever`); `GET /sitemap.xml` → `PublicSitemapController`; `RebuildCmsSitemapJob`; `php artisan cms:sitemap:rebuild` (`--dry-run` ile stdout). +- **Config:** `config/merges/cms_sitemap.php` (`modularity.cms_sitemap`); açıklama: [`docs/CONFIG.md`](../docs/CONFIG.md) (Sitemap bölümü). +- **Kalan (ürün / panel):** Cms Sitemap **alt modül** UI (dry-run/commit ekranı), parça sitemap index; isteğe bağlı sitemap’i `robots.txt` / Site SEO’ya bağlama. + +--- + +*Bu belge plan iterasyonudur; onay sonrası implementasyona ayrı PR ile gidilir. §10, sonraki agent için checklist olarak güncellenir.* diff --git a/docs/src/.vitepress/config.mjs b/docs/src/.vitepress/config.mjs index 3c495732c..b9f7a6d4f 100644 --- a/docs/src/.vitepress/config.mjs +++ b/docs/src/.vitepress/config.mjs @@ -7,12 +7,12 @@ import { sidebarConfig } from './sidebar-config.mjs' // https://vitepress.dev/reference/site-config export default defineConfig({ ...shared, - description: "Modularity Docs", + description: "Modularous Docs", themeConfig: { ...navConfig, ...sidebarConfig, socialLinks: [ - { icon: 'github', link: 'https://github.com/unusualify/modularity' }, + { icon: 'github', link: 'https://github.com/unusualify/modularous' }, ], }, diff --git a/docs/src/.vitepress/nav-config.mjs b/docs/src/.vitepress/nav-config.mjs index 6b40922ac..d2ac63640 100644 --- a/docs/src/.vitepress/nav-config.mjs +++ b/docs/src/.vitepress/nav-config.mjs @@ -3,7 +3,7 @@ import { defineConfig } from "vitepress"; export const navConfig = defineConfig({ nav: [ { text: 'Home', link: '/' }, - { text: 'Get Started', link: 'get-started/what-is-modularity' }, + { text: 'Get Started', link: 'get-started/what-is-modularous' }, { text: 'Custom Auth Pages', link: 'guide/custom-auth-pages' }, { text : 'Version' , diff --git a/docs/src/.vitepress/shared.mjs b/docs/src/.vitepress/shared.mjs index 0c92d69e8..d3c223a9c 100644 --- a/docs/src/.vitepress/shared.mjs +++ b/docs/src/.vitepress/shared.mjs @@ -2,7 +2,7 @@ import { defineConfig } from "vitepress"; export const shared = defineConfig({ - title: "Modularity", + title: "Modularous", srcDir: 'pages', outDir: '../build', cleanUrls: true, diff --git a/docs/src/.vitepress/sidebar-config.mjs b/docs/src/.vitepress/sidebar-config.mjs index bf1de7fac..edc74ac62 100644 --- a/docs/src/.vitepress/sidebar-config.mjs +++ b/docs/src/.vitepress/sidebar-config.mjs @@ -1,5 +1,6 @@ import { defineConfig } from 'vitepress' import sidebarGenerate from './sidebar-generator-v2.mjs' + export const sidebarConfig = defineConfig({ sidebar: await sidebarGenerate() }) diff --git a/docs/src/.vitepress/sidebar-generator-v2.mjs b/docs/src/.vitepress/sidebar-generator-v2.mjs index f9ecff343..8c8e5300a 100644 --- a/docs/src/.vitepress/sidebar-generator-v2.mjs +++ b/docs/src/.vitepress/sidebar-generator-v2.mjs @@ -12,6 +12,7 @@ const readFrontMatterSync = (fname) => { return { sidebarPos: data?.sidebarPos ?? 99, text: data?.sidebarTitle ?? '', + groupTitle: data?.sidebarGroupTitle ?? null, } } catch (error) { return { @@ -24,58 +25,113 @@ const generateFileName = (fname = '') => { return fname.split('-').map(word => word.charAt(0).toUpperCase().concat(word.slice(1))).join(' ').replace('.md', '') } +const normalizeSidebarText = (text = '', link = '') => { + if (!text) return text + return text +} + +const isGuideConsolePath = (pathParts = []) => pathParts[0] === 'guide' && pathParts[1] === 'console' +const isSystemReferencePath = (pathParts = []) => pathParts[0] === 'system-reference' +const useAlphaSortWithOverviewFirst = (pathParts = []) => + isGuideConsolePath(pathParts) || isSystemReferencePath(pathParts) + +const isOverviewEntry = (item = {}) => { + const link = item?.link ?? '' + return link.endsWith('/overview') || link.endsWith('/') +} + +const sortSidebarItems = (items = [], useAlphabeticalOrdering = false) => { + if (!useAlphabeticalOrdering) { + return [...items].sort((a, b) => (a.sidebarPos ?? 99) - (b.sidebarPos ?? 99)) + } + + return [...items].sort((a, b) => { + const aOverview = isOverviewEntry(a) + const bOverview = isOverviewEntry(b) + if (aOverview !== bOverview) return aOverview ? -1 : 1 + return (a.text ?? '').localeCompare(b.text ?? '', undefined, { sensitivity: 'base' }) + }) +} + +const getOverviewLabel = (rawText = '', dirName = '', pathParts = []) => { + const text = (rawText || '').trim() + if (isGuideConsolePath(pathParts)) return 'Overview' + if (isSystemReferencePath(pathParts)) return 'Overview' + if (!text) return 'Overview' + if (/^overview$/i.test(text)) return 'Overview' + return text.replace(/\s+Overview$/i, '').trim() || 'Overview' +} + /** Full path for sidebar link (cleanUrls: no .md, leading slash) */ const toSidebarLink = (pathSegments) => { const pathStr = pathSegments.filter(Boolean).join('/').replace(/\.md$/, '') return pathStr ? `/${pathStr}` : '/' } +/** Resolve the landing file for a directory. Prefer index.md (folder route), fall back to overview.md. */ +const resolveLanding = (dirAbs, dirSegments) => { + const indexPath = path.join(dirAbs, 'index.md') + if (fs.existsSync(indexPath)) { + return { file: indexPath, link: toSidebarLink(dirSegments) + '/', name: 'index.md' } + } + const overviewPath = path.join(dirAbs, 'overview.md') + if (fs.existsSync(overviewPath)) { + return { file: overviewPath, link: toSidebarLink([...dirSegments, 'overview']), name: 'overview.md' } + } + return null +} + const readLevel = (pagesDir, to) => { const itemList = [] const targetPath = path.join(pagesDir, to) const pathParts = to.split(/[/\\]/).filter(Boolean) + const landing = resolveLanding(targetPath, pathParts) + const landingName = landing?.name + const dirs = fs.readdirSync(targetPath, { withFileTypes: true }) dirs.forEach((dir) => { - if (dir.isFile() && !dir.name.includes('index')) { + if (dir.isFile()) { + if (dir.name === 'index.md') return + if (dir.name === landingName) return + if (!dir.name.endsWith('.md')) return + const filematter = readFrontMatterSync(path.join(targetPath, dir.name)) const link = toSidebarLink([...pathParts, dir.name]) itemList.push({ - text: filematter?.text || generateFileName(dir.name), + text: normalizeSidebarText(filematter?.text || generateFileName(dir.name), link), link, sidebarPos: filematter.sidebarPos, }) } else if (dir.isDirectory()) { const subPath = path.join(to, dir.name) const subPathNorm = subPath.replace(/\\/g, '/').split('/').filter(Boolean) - const indexPath = path.join(targetPath, dir.name, 'index.md') - const filematter = readFrontMatterSync(indexPath) + const subLanding = resolveLanding(path.join(targetPath, dir.name), subPathNorm) + const filematter = subLanding ? readFrontMatterSync(subLanding.file) : {} const childItems = readLevel(pagesDir, subPath) - const hasIndex = fs.existsSync(indexPath) - const overviewItem = hasIndex + const overviewItem = subLanding ? { - text: filematter?.text || generateFileName(dir.name) + ' Overview', - link: toSidebarLink(subPathNorm) + '/', + text: getOverviewLabel(filematter?.text || generateFileName(dir.name) + ' Overview', dir.name, subPathNorm), + link: subLanding.link, sidebarPos: 0, } : null const group = { - text: generateFileName(dir.name), + text: filematter?.groupTitle || generateFileName(dir.name), collapsed: true, sidebarPos: filematter?.sidebarPos ?? 99, items: overviewItem - ? [overviewItem, ...childItems].sort((a, b) => (a.sidebarPos ?? 99) - (b.sidebarPos ?? 99)) + ? sortSidebarItems([overviewItem, ...childItems], useAlphaSortWithOverviewFirst(subPathNorm)) : childItems, } itemList.push(group) } }) - itemList.sort((a, b) => (a.sidebarPos ?? 99) - (b.sidebarPos ?? 99)) - return itemList + return sortSidebarItems(itemList, useAlphaSortWithOverviewFirst(pathParts)) } export default async function(srcDir) { @@ -86,12 +142,11 @@ export default async function(srcDir) { .map((d) => d.name) const sidebarConfig = rawDirNames.map((dir) => { - const indexPath = path.join(pagesDir, dir, 'index.md') - const hasIndex = fs.existsSync(indexPath) + const landing = resolveLanding(path.join(pagesDir, dir), [dir]) return { text: generateFileName(dir), collapsed: true, - ...(hasIndex && { link: `/${dir}/` }), + ...(landing && { link: landing.link }), items: readLevel(pagesDir, dir), } }) diff --git a/docs/src/pages/get-started/creating-modules.md b/docs/src/pages/get-started/creating-modules.md index f106d8446..3791b8765 100644 --- a/docs/src/pages/get-started/creating-modules.md +++ b/docs/src/pages/get-started/creating-modules.md @@ -1,7 +1,6 @@ --- -# https://vitepress.dev/reference/default-theme-home-page sidebarPos: 4 - +sidebarTitle: Creating Modules --- # Creating a Module @@ -16,7 +15,7 @@ Running this command will create the module with empty module structure with a c Creating module and a module options are similar while default option of the creating a module is generating a plain folder structure for the given module name. ::: ::: info -Creating module and route options are similar if default option is not used and parent domain entity is created. Options will be explain under creating route header +Creating module and route options are similar if default option is not used and parent domain entity is created. Options will be explained under creating route header ::: **Config File** @@ -37,15 +36,42 @@ return [ ]; ``` -where you can configure your modules `headline` presenting on sidebar, whether the module route will be generated with system_prefix or base_prefix. ``Routes`` key will contain all your route configrations generated. Them can be customized in future. +where you can configure your modules `headline` presenting on sidebar, whether the module route will be generated with system_prefix or base_prefix. ``Routes`` key will contain all your route configurations generated. They can be customized in future. ::: tip -Config file can be customized in many ways, see [Module Config](/) +Config file can be customized in many ways. ::: <br/> **File module.json** +Every module contains a `module.json` manifest at its root. The framework scans this file to discover, register, and boot the module. A generated manifest looks like this: + +```json +{ + "name": "Authentication", + "alias": "authentication", + "description": "", + "keywords": [], + "priority": 0, + "providers": [], + "files": [] +} +``` + +| Key | Type | Description | +|:----|:-----|:------------| +| `name` | string | The canonical module name. Must match the folder name exactly. | +| `alias` | string | Snake-case or kebab-case identifier used internally for route prefixes and cache keys. | +| `description` | string | Optional human-readable summary of the module's purpose. | +| `keywords` | array | Tags associated with the module (informational only). | +| `priority` | integer | Load order relative to other modules. Higher values load first. Defaults to `0`. | +| `providers` | array | Fully-qualified class names of additional service providers to register alongside the module's default provider. | +| `files` | array | Extra files to autoload when the module boots. | + +::: tip +You rarely need to edit `module.json` by hand. The `modularity:make:module` and `modularity:make:route` commands keep it up to date automatically. +::: ## Creating Routes Creating a route is highly customizable using command options, simplest way to create a route with default schema and relationship options is: @@ -59,7 +85,7 @@ As mentioned, config.php file underneath the module folder can and should be use ::: tip IMPORTANT -This documentation will include brief explanation of the technical information about create route command. For further presentation about Modularity Know-how please see [Examples] +This documentation will include brief explanation of the technical information about create route command. For further presentation about Modularous Know-how please see [Examples] ::: ## Artisan Command Options @@ -68,22 +94,22 @@ This documentation will include brief explanation of the technical information a #### `--schema` Use this option to define your model's database schema. It will automatically configure your migration files. #### `--relationships` -Relationships option should not be confict with migration relationships. Database migrations should be set on the `--schema` option. On the other hand, `--relationship` options will be used to define model relationship methods like `Polymorphic Relationships` where you need a pivot or any other external database table to define relationships. See [Example Page] +Relationships option should not conflict with migration relationships. Database migrations should be set on the `--schema` option. On the other hand, `--relationship` options will be used to define model relationship methods like `Polymorphic Relationships` where you need a pivot or any other external database table to define relationships. See [Example Page] #### `--rules` -Rules options will be used to define CRUD form validations for both backend and front-end validation scripts. +Rules options will be used to define CRUD form validations for both backend and frontend validation scripts. #### `--no-migrate` Default route generation automatically runs migrations. You can skip migration with this option. #### `--force` Force the operation to run when the route files already exist. Use this option to override the route files with new options. ## Defining Model Schema -Model schema is where you define your enties' attributes (columns) and these attributes' types and modifiers. Modularity schema builder contains all availiable column types and column modifiers in Laravel Framework +Model schema is where you define your entities attributes (columns) and these attributes types and modifiers. Modularous schema builder contains all available column types and column modifiers in Laravel Framework -( See [Availiable Laravel Column Types](https://laravel.com/docs/11.x/migrations#available-column-types){target="_self"} - [Available Laravel Column Modifiers](https://laravel.com/docs/11.x/migrations#column-modifiers){target="_self"} ) +( See [Available Laravel Column Types](https://laravel.com/docs/migrations#available-column-types){target="_self"} - [Available Laravel Column Modifiers](https://laravel.com/docs/migrations#column-modifiers){target="_self"} ) ::: danger Relationships -Defining relation type attributes are different in Unusualify/Modularity. Please see [Defining Relationships](#defining-relations-between-routes) +Defining relation type attributes are different in Unusualify/Modularous. Please see [Defining Relations Between Routes](#defining-relations-between-routes) ::: @@ -91,7 +117,7 @@ Defining relation type attributes are different in Unusualify/Modularity. Please **Defining a series of attributes** -When defining a series of entity attributes, desired schema should be typed between double quotes `"`, columnTypes should be seperated by colons `:` and lastly attributes should be seperated by commas `,` if exist. +When defining a series of entity attributes, desired schema should be typed between double quotes `"`, columnTypes should be separated by colons `:` and lastly attributes should be separated by commas `,` if exist. ```sh $ php artisan modularity:make:route ModuleName RouteName --schema="attributeName:columnType#1:columnType#2,attributeName#2:...columnType#:..columnModifiers#" @@ -99,12 +125,12 @@ $ php artisan modularity:make:route ModuleName RouteName --schema="attributeName Running this command will generate your model's - `controller`, with source methods - `migration` files with defined columns - - `routes`, + - `routes` - `entity` with fillable array, - `request` with default methods - `repository` - `index` and `form` blade components with default configuration - - also module config file will be overriden with route properties + - also module config file will be overridden with route properties ::: tip Module Config.php Module config file is where user interface, CRUD form schema and etc. can be customized. Please see [Module Config] @@ -120,7 +146,7 @@ $ php artisan modularity:make:route Authentication User --schema="name:string,em ## **Defining relations between routes** -In Laravel migrations, only `foreignId` and `morphs` column types can be used to define relationsips between models. In Modularity, `reverse relationship method names` can be used as an attribute while creating route. +In Laravel migrations, only `foreignId` and `morphs` column types can be used to define relationships between models. In Modularous, `reverse relationship method names` can be used as an attribute while creating route. ::: warning Reverse Relations Since creating route command will automatically create all of the required files and running migrations, it is suggested to follow `reverse relationship` path to define relation between models @@ -146,13 +172,13 @@ cars Following the given example, creating user route: ```sh -$ php artisan modularity:make:route Aparment Citizen --schema="name:string,citizen_id:integer:unique" +$ php artisan modularity:make:route Apartment Citizen --schema="name:string,citizen_id:integer:unique" ``` `Citizen` route is now generated with all required files. Next, we can create `Car` route with `belongsTo` relationship related column(s) and model method(s) with the following artisan command: ```sh -$ php artisan modularity:make:route Aparment Car --schema="model:string,plate:string:unique,citizen:belongsTo" +$ php artisan modularity:make:route Apartment Car --schema="model:string,plate:string:unique,citizen:belongsTo" ``` -Runnings these couple of commands, will also create relationship related model methods as: +Running these couple of commands, will also create relationship related model methods as: ```php // Citizen.php @@ -175,11 +201,11 @@ $table->foreignId('testify_id')->constrained->onUpdate('cascade')->onDelete('cas ::: tip Relationship Summary -While defining direct relationships that will affect migration and database tables, `--schema` option should be used. On the other hand, with un-direct relations like `many-to-many` and `through` relations you need to use `--relationships` option. This option will set required pivot table and required model methods without altering migration files. +While defining direct relationships that will affect migration and database tables, `--schema` option should be used. On the other hand, with undirect relations like `many-to-many` and `through` relations you need to use `--relationships` option. This option will set required pivot table and required model methods without altering migration files. ::: ### Available Relationship Methods -For this version of `Unusualify/Modularity`, available relationship methods can be defined are: +For this version of `Unusualify/Modularous`, available relationship methods can be defined are: | Reverse Relationship| Relationship| |:--------------------|------------:| | belongsTo | hasMany | @@ -188,5 +214,5 @@ For this version of `Unusualify/Modularity`, available relationship methods can | hasOneThrough | hasOneThrough | ::: info ToMany Relationship Usage -Since * to many relations provides the same functionality with the * to one relations, `Unusualify/Modularity` serves only * to many relationship methods and migrations. Cases with * to one relationship usage, it can be supplied with request validations. +Since * to many relations provides the same functionality with the * to one relations, `Unusualify/Modularous` serves only * to many relationship methods and migrations. Cases with * to one relationship usage, it can be supplied with request validations. ::: diff --git a/docs/src/pages/get-started/index.md b/docs/src/pages/get-started/index.md deleted file mode 100644 index 7161d2e57..000000000 --- a/docs/src/pages/get-started/index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -sidebarPos: 1 -sidebarTitle: Get Started Overview ---- - -# Get Started - -This section helps you understand Modularity and set up your first module. - -## Contents - -| Page | Description | -|------|-------------| -| [What is Modularity](/get-started/what-is-modularity) | Package overview, developer experience | -| [What is Modular Design](/get-started/what-is-modular-design) | Modular approach, project structure | -| [Installation Guide](/get-started/installation-guide) | Install and configure the package | -| [Creating Modules](/get-started/creating-modules) | Create your first module | - -## Next Steps - -- [Guide](/guide/) — UI components, forms, tables -- [Module Features](/guide/module-features/) — Feature matrix, traits, hydrates -- [Generics](/guide/generics/) — Allowable, Relationships, Files and Media -- [Commands](/guide/commands/) — Artisan commands reference -- [System Reference](/system-reference/) — Architecture, Hydrates, Repositories -- [Custom Auth Pages](/guide/custom-auth-pages/overview) — Customize auth layout diff --git a/docs/src/pages/get-started/installation-guide.md b/docs/src/pages/get-started/installation-guide.md index 7e5bb03c4..b15406a79 100644 --- a/docs/src/pages/get-started/installation-guide.md +++ b/docs/src/pages/get-started/installation-guide.md @@ -3,43 +3,43 @@ sidebarPos: 3 --- -# Modularity Setup -This document will discuss about installation and required configurations for installation of the package. +# Modularous Setup +This document will discuss installation and required configurations for the package. ## Pre-requisites The modules package requires **PHP XXX** or higher and also requires **Laravel 10** or higher. -## Creating a Modularity Project +## Creating a Modularous Project -### Using Modularity-Laravel Boilerplate +### Using Modularous-Laravel Boilerplate -Modularity provides a Laravel boilerplate that all the pre-required files such as config files, environment file and etc published, and the folder structure is built as Modularity does. In order to create a modularity-laravel project following ``shell`` command can be used: +Modularous provides a Laravel boilerplate that all the pre-required files such as config files, environment file and etc published, and the folder structure is built as Modularous does. In order to create a Modularous-laravel project following ``shell`` command can be used: After `cd` to your preferred directory for your project, ```sh -$ composer create-project unusualify/modularity-laravel your-project-name +$ composer create-project unusualify/Modularous-laravel your-project-name ``` ::: tip -After the setup is done, you can customize the config files and follow the intallation steps with `Only Database Operations`. Please proceed with +After the setup is done, you can customize the config files and follow the installation steps with `Only Database Operations`. Please proceed with [Installation Wizard](#installation-wizard) ::: ### Using Default Laravel Project -1. **Intalling Modularity** +1. **Installing Modularous** After creating a default Laravel project, cd to your project folder ```sh $ cd your-project-folder ``` -To install Modularity via Composer, run the following shell command: +To install Modularous via Composer, run the following shell command: ```sh $ composer require unusualify/modularity ``` After the installation of the package is done run: ```sh -$ php artisan vendor:publish --provider='Unusualify\\Modularity\\LaravelServiceProvider' +$ php artisan vendor:publish --provider='Unusualify\\Modularous\\LaravelServiceProvider' ``` This will publish the package's configuration files <br/><br/> @@ -47,8 +47,8 @@ This will publish the package's configuration files ## Environment File Configuration -::: warning -Configuration for many variable is ``must`` to construct your Vue & Laravel app with your project configuration before [Installation](#installation-wizard) +::: tip +Configuration for many variables is ``must`` to construct your Vue & Laravel app with your project configuration before [Installation](#installation-wizard) ::: @@ -59,7 +59,7 @@ ADMIN_APP_URL= ADMIN_APP_PATH=DESIRED_ADMIN_APP_PATH ADMIN_ROUTE_NAME_PREFIX=DESIRED_ADMIN_ROUTE_NAME_PREFIX ``` -As mentioned, modularity aims to construct your administration panel user interface while you building your project's backend application. Given key-value pairs corresponds to +As mentioned, modularous aims to construct your administration panel user interface while building your project's backend application. Given key-value pairs corresponds to * Your administration panel domain name * Your admin route path as ``'yourdomain.com/admin'`` if ``ADMIN_APP_URL`` key is not set * Your route naming prefixes for administration routes like `admin.password` @@ -79,10 +79,10 @@ The default Laravel database environment configuration must be done before insta ```sh # Laravel Development Variables MEDIA_LIBRARY_ENDPOINT_TYPE=local -MEDIA_LIBRARY_IMAGE_SERVICE=Unusualify\Modularity\Services\MediaLibrary\Local +MEDIA_LIBRARY_IMAGE_SERVICE=Unusualify\Modularous\Services\MediaLibrary\Local MEDIA_LIBRARY_LOCAL_PATH=uploads ``` -Shown key-value pairs is aims to point out the media library constructed in the `Modularity` package. For now, they are not customizable. +Shown key-value pairs aim to point out the media library constructed in the `Modularous` package. For now, they are not customizable. ```sh ACTIVITY_LOGGER_DB_CONNECTION=mysql @@ -91,7 +91,7 @@ Default system logger configuration. Again, it is not customizable for now. ```sh DEFAULT_USER_PASSWORD=DESIRED_DEFAULT_USER_PASSWORD ``` -You can set your client-users default password. It will be set as fallback password if its not set while creating user. +You can set your client-users default password. It will be set as fallback password if it's not set while creating user. **Vue Development Variables** ```sh @@ -104,10 +104,10 @@ VUE_DEV_PROXY= ``` Admin panel application user interface is highly customizable through module configs. Also you can create your own custom `Vue` components in order to use in user interface. For further information see [Vue Component Sayfası] . In summary, * A custom theme can be constructed, its name should be defined with `VUE_APP_THEME` -* Vue app locale language and fallback language should be setted -* Vue dev port should be setted, can be same as the locale port +* Vue app locale language and fallback language should be set +* Vue dev port should be set, can be same as the locale port * Vue dev host can be your domain-name like `mytestapp.com` -* Proxy should be setted if it is in undergo like `http://nginx` +* Proxy should be set if it is in undergo like `http://nginx` ::: tip @@ -116,15 +116,15 @@ You can do further custom configuration through ``config`` files which are store ## Installation Wizard -Modularity ships with a command line installation wizard that will help on scaffolding a basic project. After installation via Composer, wizard can be started by running: +Modularous ships with a command line installation wizard that will help on scaffolding a basic project. After installation via Composer, wizard can be started by running: ```sh $ php artisan modularity:install ``` Wizard will be processing with simple questions to construct projects core configurations. ``` Installment process consists of two(2) main operations. - 1. Publishing Config Files: Modularity Config files manages heavily table names, jwt configurations and etc.User should customize them after publishing in order to customize table names and other opeartions - 2. Database Operations and Creating Super Admin. DO NOT select this option if you have not published vendor files to theproject. This option will only dealing with db operations + 1. Publishing Config Files: Modularous Config files manages heavily table names, jwt configurations and etc. User should customize them after publishing in order to customize table names and other operations + 2. Database Operations and Creating Super Admin. DO NOT select this option if you have not published vendor files to the project. This option will only dealing with db operations 3. Complete Installment with default configurations (√ suggested) @@ -135,12 +135,12 @@ Installment process consists of two(2) main operations. └──────────────────────────────────────────────────────────────┘ ``` ::: info Installation Options -A Modularity Project heavily depends on the configration files that will be published under your-project/config directory. Modularity comes with a series of default configuration, however they can be customized before Database Operations +A Modularous Project heavily depends on the configuration files that will be published under your-project/config directory. Modularous comes with a series of default configuration, however they can be customized before Database Operations ::: ::: tip Customization -This page will be continue with the complete installment option with the default configrations. See [Config Customization] to inspect other options +This page will be continue with the complete installment option with the default configurations. See [Config Customization] to inspect other options ::: Starting installation with the `Complete Installment` option will, @@ -149,7 +149,7 @@ Starting installation with the `Complete Installment` option will, - Seed default data for the system modules automatically after publishing default assets, views and configuration files to your project. -For the last step, intallation process includes creating a super-admin account +For the last step, installation process includes creating a super-admin account ``` Creating super-admin account @@ -173,11 +173,11 @@ You can either select the default settings or type your custom e-mail and passwo ::: ::: details Creating Super Admin -Creating one or more super-admin account with custom e-mail and password is avaliable. See [Creating Super Admin] +Creating one or more super-admin account with custom e-mail and password is available. See [Creating Super Admin] ::: ## File Structure -A `Modularity Module` is similar to a Laravel package. It has its own, configs, controllers, migrations and etc. This file structure aims to writing modular applications and have more organized project to work with. +A `Modularous Module` is similar to a Laravel package. It has its own, configs, controllers, migrations and etc. This file structure aims to writing modular applications and have more organized project to work with. Assuming installment is done and a test module `Testify` is created ``` diff --git a/docs/src/pages/get-started/overview.md b/docs/src/pages/get-started/overview.md new file mode 100644 index 000000000..b718e0739 --- /dev/null +++ b/docs/src/pages/get-started/overview.md @@ -0,0 +1,28 @@ +--- +sidebarPos: 1 +sidebarTitle: Get Started Overview +--- + +# Get Started + +This section helps you understand Modularous and set up your first module. + +## Contents + +| Page | Description | +|------|-------------| +| [What is Modularous?](/get-started/what-is-modularous) | Package overview, developer experience | +| [What is Modular Design?](/get-started/what-is-modular-design) | Modular approach, project structure | +| [Installation Guide](/get-started/installation-guide) | Install and configure the package | +| [Creating Modules](/get-started/creating-modules) | Create your first module | +| [Upgrading](/get-started/upgrading) | Upgrade version | + + +## Next Steps + +- [Guide](/guide/overview) — UI components, forms, tables +- [Module Features](/guide/module-features/overview) — Feature matrix, traits, hydrates +- [Generics](/guide/generics/overview) — Allowable, Relationships, Files and Media +- [Commands](/guide/console/overview) — Artisan commands reference +- [System Reference](/system-reference/overview) — Architecture, Hydrates, Repositories +- [Custom Auth Pages](/guide/custom-auth-pages/overview) — Customize auth layout diff --git a/docs/src/pages/get-started/upgrading.md b/docs/src/pages/get-started/upgrading.md new file mode 100644 index 000000000..26bce4559 --- /dev/null +++ b/docs/src/pages/get-started/upgrading.md @@ -0,0 +1,213 @@ +--- +sidebarPos: 5 +sidebarTitle: Upgrading +--- + +# Upgrading Modularous + +This guide covers the general upgrade procedure plus notes on recent releases. For a complete list of changes, see the [CHANGELOG](https://github.com/unusualify/modularous/blob/main/CHANGELOG.md). + +## Versioning + +Modularous follows **SemVer-ish** versioning while the package is pre-1.0: + +- **Minor** (`0.x.y` → `0.(x+1).0`) — may contain breaking changes; always review release notes +- **Patch** (`0.x.y` → `0.x.(y+1)`) — bug fixes and non-breaking additions only + +::: warning Pre-1.0 +Until `1.0.0`, minor releases may introduce breaking changes. Pin with `~0.58.0` to allow patches but block minors, or `^0.58.0` to allow any compatible minor. +::: + +--- + +## General Upgrade Procedure + +Run this sequence for any upgrade (minor or patch): + +### 1. Update the package + +```bash +composer require unusualify/modularous:^0.58 --update-with-all-dependencies +``` + +### 2. Republish configuration (if prompted) + +```bash +php artisan vendor:publish --tag=modularity-config --force +``` + +Diff the result against your existing config before committing — `--force` overwrites. + +### 3. Re-sync host Laravel configs + +```bash +php artisan modularity:update:laravel:configs +``` + +Patches `config/auth.php` guards and related files. See [update:laravel:configs](/guide/console/update/update-laravel-configs). + +### 4. Run migrations + +```bash +php artisan modularity:migrate +``` + +### 5. Republish stubs (if you generate code) + +```bash +php artisan modularity:make:stubs --force +``` + +Only needed if you have published stubs locally. Skip otherwise. + +### 6. Rebuild frontend + +```bash +npm install +php artisan modularity:build +``` + +### 7. Clear caches + +```bash +php artisan modularity:cache:clear +php artisan config:clear +php artisan view:clear +``` + +### 8. Smoke test + +- Log in as admin +- Load a data-table view +- Create / edit a record on any module +- Run any custom test suite + +--- + +## Version-Specific Notes + +### v0.58.0 (2026-03-24) + +Feature release with substantial additions — review these before upgrading. + +#### New features you can adopt + +- **Currency provider interface** — modules can now provide their own currency data source. See [Currency providers](/system-reference/backend/services/currency/overview). +- **2FA login routes** — enable via the auth component config. +- **Command discovery** — `php artisan modularity:list` now scans all registered commands. +- **Route status command** — `php artisan modularity:route:status` lists enable/disable status per module. +- **Ziggy integration** — Laravel routes are exposed to the Vue side via Ziggy. +- **Custom exception class** — `Unusualify\Modularous\Exceptions\*` replaces direct `\Exception` throws. +- **Deferred auth config** — auth component and pages now use deferred config. Re-run `modularity:update:laravel:configs` to pick up defaults. +- **InputRenderer + registry** — dynamic component mapping for form inputs. Existing `registerInputType` calls keep working. + +#### Breaking / behaviour changes + +- Composition API is now enforced for new Vue components. Existing Options API components keep working; new generator output is Composition. +- Auth views are published separately — run `php artisan vendor:publish --tag=modularity-auth-views` if you customised them. + +#### Migration checklist + +- [ ] Run the general upgrade procedure above +- [ ] If you rely on currency exchange, either install `SystemPricing` or implement a custom `CurrencyProviderInterface` binding +- [ ] If you customised auth views, republish with `--tag=modularity-auth-views` +- [ ] Review `config/modularity.php` for new keys after `--force` republish + +### v0.57.x + +Patch releases only — safe to upgrade with the general procedure. + +### Older versions + +For upgrades across multiple minors (e.g. v0.55 → v0.58), upgrade one minor at a time and run the procedure between each. Skipping minors often surfaces cumulative breaking changes all at once. + +--- + +## Rollback + +If an upgrade breaks something and you need to revert: + +### 1. Downgrade the package + +```bash +composer require unusualify/modularous:0.58.0 --update-with-all-dependencies +# replace 0.58.0 with the previous known-good version +``` + +### 2. Roll back migrations + +```bash +php artisan modularity:migrate:rollback +``` + +Rollback only undoes the **last batch**. For multiple batches, run repeatedly — or check the migration table and target specific ones. + +### 3. Clear all caches + +```bash +php artisan modularity:cache:clear +php artisan config:clear +php artisan view:clear +``` + +### 4. Restore customised files + +If `vendor:publish --force` overwrote a customised file and you didn't diff before committing, restore it from git: + +```bash +git checkout HEAD -- config/modularity.php +``` + +--- + +## Troubleshooting + +### Composer resolver complains about conflicts + +Run with `--with-all-dependencies` (or `-W`) to allow transitive updates: + +```bash +composer require unusualify/modularous:^0.58 -W +``` + +### Migrations fail on upgrade + +Check `database/migrations/` and `modules/*/Database/Migrations/` for duplicate migration names. Modularous migrations are namespaced by module; if a host-app migration collides, rename it. + +### Form inputs render as plain text after upgrade + +The input registry may not have loaded. Clear and rebuild: + +```bash +php artisan modularity:cache:clear +php artisan modularity:build +``` + +### Echo / broadcast events stop firing + +After upgrading broadcasting, re-publish Reverb config and confirm queue workers restarted: + +```bash +php artisan config:clear +php artisan queue:restart +php artisan reverb:restart +``` + +See [Broadcasting troubleshooting](/guide/broadcasting/troubleshooting). + +--- + +## Before You Upgrade in Production + +- [ ] Pin the current version in `composer.json` so a `composer update` doesn't move further +- [ ] Run the upgrade in a staging environment first +- [ ] Take a database snapshot +- [ ] Confirm you have a tested rollback path +- [ ] Schedule during a low-traffic window +- [ ] Keep the previous package cache (`vendor/`) available for fast revert + +## See Also + +- [CHANGELOG](https://github.com/unusualify/modularous/blob/main/CHANGELOG.md) — full release history +- [Installation Guide](/get-started/installation-guide) — fresh-install instructions +- [Commands / Update](/guide/console/update/overview) — commands involved in upgrading diff --git a/docs/src/pages/get-started/what-is-modular-design.md b/docs/src/pages/get-started/what-is-modular-design.md index e1f3ea146..12e3bec7e 100644 --- a/docs/src/pages/get-started/what-is-modular-design.md +++ b/docs/src/pages/get-started/what-is-modular-design.md @@ -1,17 +1,17 @@ --- sidebarPos: 2 - +sidebarTitle: What is Modular Design Architecture? --- # What is Modular Design Architecture? -In summary, `Modular Design` can be defined as an approach to dividing code files into smaller sub-parts and layers by separating and `isolating` them sub-parts from each other. +In summary, `Modular Design` can be `defined` as an approach to dividing code files into smaller sub-parts and layers by separating and `isolating` these sub-parts from each other. ## Problem Statement -As the project grows, business logic of `multiple` features tends to affect other code spaces in the project. That might be blocking co-developers to undergo their tasks, can produce dependency injection problems, code bugs and code-conflicts with making `multiple` different tasks or `features affecting each other`. Lastly, it would increase testing processes, the app built time due to codebase growth. In conclusion, all things considered it would reduce developer’s productivity and production efficiency, `increase development complexity`. +As the project grows, business logic of `multiple` features tends to affect other code spaces in the project. That might be blocking co-developers to undergo their tasks, can produce dependency injection problems, code bugs and code-conflicts with making `multiple` different tasks or `features affecting each other`. Lastly, it would increase testing processes, the app built time due to codebase growth. In conclusion, all things considered it would reduce developer’s productivity and production efficiency, `increase development complexity`. ## Modular Design Solution -Dealing with the mentioned problems is possible with making code-space and features `seperated` into layers that will work `independently` from each other as much as possible. In this way, `feature based` development become available and its enables us to making features independent from each other. Consequently, a feature can be built as an project or `re-usable generic package`, code becomes more `SOLID`. +Dealing with the mentioned problems is possible with making code-space and features `separated` into layers that will work `independently` from each other as much as possible. In this way, `feature based` development becomes available and it enables us to make features independent from each other. Consequently, a feature can be built as a project or `re-usable generic package`, code becomes more `SOLID`. ## Benefits of Modular System Design #### Increasing Code Reusability @@ -20,23 +20,23 @@ When the application is in modular form, a module can be easily imported and tra #### Feature Based Development -It is the approach of separating the existing features in the application module by module and making the features independent from each other. A change in one feature does not affect another feature. In this way, it is sufficient to run only the tests related to the relevant module. Provided that, features can be transform into `re-usable package` to our code space. +It is the approach of separating the existing features in the application module by module and making the features independent from each other. A change in one feature does not affect another feature. In this way, it is sufficient to run only the tests related to the relevant module. Provided that, features can be transformed into `re-usable package` to our code space. -#### Increasing Scability +#### Increasing Scalability -Applying modular system design and feature based development to the project code base, provides seperating whole project to smaller pieces. That way, developers can apply `Seperation of Concern princible` to the project code-base, thus each piece can be dealt with different developer. With this, each developer will be responsible for just some modules instead of whole project. +Applying modular system design and feature based development to the project code base, provides separating whole project to smaller pieces. That way, developers can apply `Separation of Concerns principle` to the project code-base, thus each piece can be dealt with different developer. With this, each developer will be responsible for just some modules instead of whole project. #### Increasing Maintainability In large - monolithic applications, any change is made in non-modular code-space may require version control of large scaled and too much code files. On the other hand, with using modular architecture, mostly `less code file` will be examined by `observing module or feature related codes`. In this way, the majority of the project is dealt with relatively less code instead of scanning and trying to understand. Detecting the error and solving the bugs becomes easier and the `time is shortened`. -::: info Feature Based Development in Modularity -Using feature based development, `Unusualify/Modularity` provides development packages like -[Laravel-Form](https://github.com/unusualify/laravel-form){target="_self"} and [Pricable](https://github.com/unusualify/priceable){target="_self"} which can be added to any project using composer. +::: info Feature Based Development in Modularous +Using feature based development, `Unusualify/Modularous` provides development packages like +[Laravel-Form](https://github.com/unusualify/laravel-form){target="_self"} and [Pricablex](https://github.com/unusualify/priceable){target="_self"} which can be added to any project using composer. ::: ## Module, Model and Route Definition Comparison -The term `Module` refers to the subject area or problem space that the software system is being designed do address. Assume building a E-Commerce application, to operate this type of application, it is necessary to integrate various areas like `Sales`, `Advertisement`,`Customer Management` and so on. The `Module` represents each of these `specific area` of business focus that the software is intented to support. +The term `Module` refers to the subject area or problem space that the software system is being designed to address. Assume building a e-commerce application, to operate this type of application, it is necessary to integrate various areas like `Sales`, `Advertisement`, `Customer Management` and so on. The `Module` represents each of these `specific area` of business focus that the software is intended to support. ::: info Module in Laravel Each module similar to a complete Laravel project. Every module will have its controllers, views, routes, middlewares, and etc. which are belonging to `module's routes`. @@ -53,7 +53,8 @@ For an example, imagine building a ``Authorization`` module with: * Roles Permissions Since authorization will be dealt with the User model itself, and capabilities of a user will be assigned with its role and roles permissions there is no need to have any `Authorization` model in the package. Now, Authorization can be constructed as a plain module structure then mentioned routes are can be constructed in it. -``` + +<pre> ├─ Authorization | ├─ Config | └─ config.php @@ -82,5 +83,5 @@ Since authorization will be dealt with the User model itself, and capabilities o | └─ composer.json | └─ module.json | └─ routes_statuses.json* -``` +</pre> diff --git a/docs/src/pages/get-started/what-is-modularity.md b/docs/src/pages/get-started/what-is-modularous.md similarity index 61% rename from docs/src/pages/get-started/what-is-modularity.md rename to docs/src/pages/get-started/what-is-modularous.md index 4c4629a72..0211a7e71 100644 --- a/docs/src/pages/get-started/what-is-modularity.md +++ b/docs/src/pages/get-started/what-is-modularous.md @@ -1,39 +1,43 @@ --- sidebarPos: 1 - +sidebarTitle: What is Modularous? --- -# What is Modularity -[Unusualify/Modularity](https://github.com/unusualify/modularity) is a Laravel and Vuetify.js powered, developer tool that aims to improve developer experience on conducting full stack development process. On Laravel side, Modularity manages your large scale projects using modules, where a module similar to a single Laravel project, having some views, controllers or models. With the abilities of Vuetify.js, Modularity presents various of dynamic, configurable UI components to auto-construct a CRM for your project. +# What is Modularous? +[Unusualify/Modularous](https://github.com/unusualify/modularous) is a Laravel and Vuetify.js powered, developer tool that aims to improve developer experience on conducting full stack development process. On Laravel side, Modularous manages your large scale projects using modules, where a module similar to a single Laravel project, having some views, controllers or models. With the abilities of Vuetify.js, Modularous presents various of dynamic, configurable UI components to auto-construct a CRM for your project. ## Developer Experience -Modularity aims to provide a greate Developer Experince when working on full-stack development process with: -- Presenting various custom artisan commands that undergoes file generation +Modularous aims to provide a great Developer Experience when working on full-stack development process with: +- Presenting various custom artisan commands that undergo file generation - Generating CRUD pages and forms based on the defined model using ability of [Vuetify.js](https://vuetifyjs.com/en/) -- Simplistic configuration or customization on the crm panel UI through config files +- Simplistic configuration or customization on the CRM panel UI through config files - Simplistic configuration of CRUD forms through config files ## Organized Project Structure -Modular approach trying to resolve the complexity with a default Laravel project structure where every business logic coming together in controllers. In modular approach, each business logic is splitted into different parts that communicate with each other. +Modular approach trying to resolve the complexity with a default Laravel project structure where every business logic coming together in controllers. In modular approach, each business logic is split into different parts that communicate with each other. Every module is similar to a Laravel project, each one has its own model, views, controllers and route files. ## Dynamic & Configurable Panel UI -Powered by [Vue.js](https://vuejs.org/guide/introduction.html){target="_self"} and [Vuetify](https://vuetifyjs.com/){target="_self"}, your application's administration panel is auto-constructed while you developing your Laravel application. +Powered by [Vue.js](https://vuejs.org/guide/introduction.html){target="_self"} and [Vuetify.js](https://vuetifyjs.com/){target="_self"}, your application's administration panel is auto-constructed while you developing your Laravel application. -With the abilities of Vuetify.js, Modularity presents various of dynamic, configurable UI components to auto-construct a CRM for your project. +With the abilities of Vuetify.js, Modularous presents various of dynamic, configurable UI components to auto-construct a CRM for your project. ## Used Packages -- [NWidart/Laravel-Modules](https://github.com/nWidart/laravel-modules){target:"_self"} : is a Laravel package created to manage your large Laravel app using modules. A Module is like a Laravel package, it has some views, controllers or models +- [NWidart/Laravel-Modules](https://github.com/nWidart/laravel-modules){target="_self"} : is a Laravel package created to manage your large Laravel app using modules. A Module is like a Laravel package, it has some views, controllers or models ## For Questions and Issues +Open a GitHub issue at [unusualify/modularous](https://github.com/unusualify/modularous/issues) for bug reports, questions, or feature requests. + ## Future Work -## Main Contributers +Planned improvements are tracked in the repository's GitHub Issues and Milestones. Community contributions are welcome — See [CONTRIBUTING.md](https://github.com/unusualify/modularous/blob/0.x/.github/CONTRIBUTING.md) in the repository for guidelines. + +## Main Contributors <script setup> import { VPTeamMembers } from 'vitepress/theme' @@ -46,12 +50,20 @@ const members = [ { icon: 'github', link: 'https://github.com/OoBook' }, ] }, + { + avatar: 'https://avatars.githubusercontent.com/u/76479640', + name: 'Erdem Çelik', + title: 'Full Stack Developer', + links: [ + { icon: 'github', link: 'https://github.com/celikerde' } + ] + }, { avatar: 'https://avatars.githubusercontent.com/u/45737685', name: 'Hazarcan Doga Bakan', title: 'Full Stack Developer', links: [ - { icon: 'github', link: 'https://https://github.com/Exarillion' }, + { icon: 'github', link: 'https://github.com/dancing-janissary' }, ] }, @@ -60,8 +72,7 @@ const members = [ name: 'Ilker Ciblak', title: 'Full Stack Developer', links: [ - { icon: 'github', link: 'https://github.com/ilkerciblak' }, - { icon: 'twitter', link: 'https://twitter.com/ilker_exe' } + { icon: 'github', link: 'https://github.com/ilkerciblak' } ] }, { @@ -71,8 +82,7 @@ const members = [ links: [ { icon: 'github', link: 'https://github.com/gunesbizim' }, ] - }, - + } ] </script> diff --git a/docs/src/pages/guide/broadcasting/overview.md b/docs/src/pages/guide/broadcasting/overview.md new file mode 100644 index 000000000..e32841875 --- /dev/null +++ b/docs/src/pages/guide/broadcasting/overview.md @@ -0,0 +1,288 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Broadcasting +outline: deep +--- + +# Broadcasting + +Modularous uses **Laravel Reverb** (or any Pusher-compatible driver) to broadcast `ModelEvent` subclasses over WebSockets. Every event that extends `ModelEvent` and implements `ShouldBroadcast` is automatically broadcast to two channels when it fires. + +## In This Section + +| Page | Purpose | +|------|---------| +| **Overview** (this page) | Setup, default channels, Echo integration | +| [Testing](./testing) | `Event::fake()` patterns, asserting channels and payloads | +| [Troubleshooting](./troubleshooting) | Common issues (`.` prefix, auth failures, duplicate dispatches, proxies) | + +Class references live under `system-reference/backend/`: + +- [ModelEvent](/system-reference/backend/events/model-event) — base class + `EventUser`, `EventUrls`, `EventChanges`, `EventStateable` traits +- [BroadcastManager](/system-reference/backend/services/broadcast-manager) — build frontend channel/event config from PHP + +--- + +## How It Works + +When a `ModelEvent` subclass that uses `InteractsWithBroadcasting` is dispatched, the constructor calls `$this->broadcastVia($this->broadcastService)` (defaults to `'reverb'`). Laravel then serializes and delivers the event payload over WebSockets. + +### Default Channels + +Every `ModelEvent` broadcasts on two channels: + +| Channel | Type | Pattern | Purpose | +|---------|------|---------|---------| +| `models.{model_id}` | Private | One channel per model instance | Per-record real-time updates | +| `model` | Public | Single shared channel | All model updates across the app | + +### Event Name Convention + +The broadcast event name is derived from the class name: + +``` +ClassName → broadcast event name +──────────────────────────────────────────── +OrderShippedEvent → modularity.order.shipped +ModelCreated → modularity.model.created +StateableUpdated → modularity.stateable.updated +``` + +The rule: strip `_event` suffix, convert to snake_case, replace `_` with `.`, prepend `modularity.`. + +--- + +## Server Configuration + +### 1. Install and Configure Reverb + +```bash +php artisan reverb:install +``` + +In `config/broadcasting.php`, ensure the `reverb` connection is configured: + +```php +'reverb' => [ + 'driver' => 'reverb', + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'timeout' => null, +], +``` + +Set the default driver to `reverb` (or switch per-event): + +```php +// config/broadcasting.php +'default' => env('BROADCAST_DRIVER', 'reverb'), +``` + +### 2. Start the Reverb Server + +```bash +php artisan reverb:start +``` + +For production, use a process manager (Supervisor, systemd) to keep the server running. + +--- + +## Creating a Broadcast Event + +To make a `ModelEvent` subclass broadcast, add `ShouldBroadcast` and `InteractsWithBroadcasting`: + +```php +use Illuminate\Broadcasting\InteractsWithBroadcasting; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Unusualify\Modularity\Events\ModelEvent; + +class OrderShipped extends ModelEvent implements ShouldBroadcast +{ + use InteractsWithBroadcasting; +} +``` + +That's all — `ModelEvent` provides `broadcastOn()` and `broadcastAs()` automatically. + +### Overriding Channels + +To broadcast on custom channels instead of the defaults: + +```php +use Illuminate\Broadcasting\PrivateChannel; + +class OrderShipped extends ModelEvent implements ShouldBroadcast +{ + use InteractsWithBroadcasting; + + public function broadcastOn(): array + { + return [ + new PrivateChannel('orders.' . $this->model->id), + new PrivateChannel('users.' . $this->model->user_id), + ]; + } +} +``` + +### Overriding the Broadcast Driver + +To use a different driver for a specific event: + +```php +class OrderShipped extends ModelEvent implements ShouldBroadcast +{ + use InteractsWithBroadcasting; + + public string $broadcastService = 'pusher'; // override from 'reverb' +} +``` + +--- + +## BroadcastManager — Building Frontend Config + +`BroadcastManager::forModel()` inspects a list of event classes for a given model and returns a structured config array that can be passed to the frontend so Laravel Echo can subscribe dynamically. + +```php +use Unusualify\Modularity\Services\BroadcastManager; +use Modules\SystemNotification\Events\ModelCreated; +use Modules\SystemNotification\Events\ModelUpdated; +use Modules\SystemNotification\Events\StateableUpdated; + +$broadcastConfig = BroadcastManager::forModel($order, [ + ModelCreated::class, + ModelUpdated::class, + StateableUpdated::class, +]); +``` + +The returned array has the shape: + +```php +[ + [ + 'name' => 'private-orders.42', + 'type' => 'private', + 'events' => [ + ['event' => 'modularity.model.created'], + ['event' => 'modularity.model.updated'], + ['event' => 'modularity.stateable.updated'], + ], + ], + [ + 'name' => 'model', + 'type' => 'public', + 'events' => [/* same events */], + ], +] +``` + +Pass this to the frontend via Inertia shared data or a Blade variable: + +```php +// In your controller or middleware: +Inertia::share('broadcastConfig', BroadcastManager::forModel($model, $eventClasses)); +``` + +→ [BroadcastManager service reference](/system-reference/backend/services/broadcast-manager) + +--- + +## Frontend — Subscribing with Laravel Echo + +### 1. Install Echo and the Reverb client + +```bash +npm install laravel-echo pusher-js +``` + +### 2. Configure Echo + +```js +// resources/js/bootstrap.js +import Echo from 'laravel-echo' +import Pusher from 'pusher-js' + +window.Pusher = Pusher + +window.Echo = new Echo({ + broadcaster: 'reverb', + key: import.meta.env.VITE_REVERB_APP_KEY, + wsHost: import.meta.env.VITE_REVERB_HOST, + wsPort: import.meta.env.VITE_REVERB_PORT ?? 80, + wssPort: import.meta.env.VITE_REVERB_PORT ?? 443, + forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', + enabledTransports: ['ws', 'wss'], +}) +``` + +### 3. Subscribe Using BroadcastManager Config + +```js +function subscribeToBroadcast(broadcastConfig) { + broadcastConfig.forEach(({ name, type, events }) => { + const channel = type === 'private' + ? Echo.private(name) + : Echo.channel(name) + + events.forEach(({ event }) => { + channel.listen(`.${event}`, (payload) => { + console.log(`Event ${event} received:`, payload) + // update your Vue store / reactive state here + }) + }) + }) +} + +// In a Vue component (using Inertia shared data): +const { broadcastConfig } = usePage().props +subscribeToBroadcast(broadcastConfig) +``` + +::: tip Event name prefix +Laravel Echo requires a leading `.` before custom event names: `.modularity.model.created`. Without it, Echo treats the name as a Laravel event class string. +::: + +### 4. Authorizing Private Channels + +Private channels require the `Broadcast::channel()` authorization to be registered. Add to `routes/channels.php`: + +```php +use Illuminate\Support\Facades\Broadcast; + +// Authorize any authenticated user to subscribe to their model's channel +Broadcast::channel('models.{modelId}', function ($user, $modelId) { + return $user !== null; // adjust to your auth logic +}); +``` + +--- + +## Environment Variables + +```dotenv +BROADCAST_DRIVER=reverb + +REVERB_APP_ID=your-app-id +REVERB_APP_KEY=your-app-key +REVERB_APP_SECRET=your-app-secret +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http + +# Frontend (Vite) +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" +``` diff --git a/docs/src/pages/guide/broadcasting/testing.md b/docs/src/pages/guide/broadcasting/testing.md new file mode 100644 index 000000000..432c45223 --- /dev/null +++ b/docs/src/pages/guide/broadcasting/testing.md @@ -0,0 +1,161 @@ +--- +sidebarPos: 2 +sidebarTitle: Testing +outline: deep +--- + +# Testing Broadcasts + +Broadcast events are **just events** — test them with Laravel's built-in `Event::fake()` / `Event::assertDispatched()` helpers. You don't need a live WebSocket server to verify that the right events fire with the right payload. + +## Fake and Assert + +```php +use Illuminate\Support\Facades\Event; +use Modules\Order\Events\OrderShipped; + +it('fires OrderShipped when status changes to shipped', function () { + Event::fake([OrderShipped::class]); + + $order = Order::factory()->create(['status' => 'paid']); + $order->update(['status' => 'shipped']); + + Event::assertDispatched(OrderShipped::class, function ($event) use ($order) { + return $event->model->is($order); + }); +}); +``` + +`Event::fake([...])` is preferred over the catch-all `Event::fake()` — it only swallows the listed events, letting other listeners (model boot events, observers) keep firing. + +## Assert Channels and Payload + +Because `broadcastOn()` and `broadcastWith()` are regular methods, you can call them directly in tests: + +```php +it('broadcasts on the correct channels', function () { + $order = Order::factory()->create(); + $event = new OrderShipped($order); + + $channels = $event->broadcastOn(); + + expect($channels)->toHaveCount(2) + ->and($channels[0]->name)->toBe('private-models.' . $order->id) + ->and($channels[1]->name)->toBe('model'); +}); + +it('sends a compact payload', function () { + $order = Order::factory()->create(['status' => 'shipped']); + $event = new OrderShipped($order); + + expect($event->broadcastWith())->toMatchArray([ + 'id' => $order->id, + 'status' => 'shipped', + ]); +}); +``` + +## Testing `broadcastWhen` + +```php +it('skips broadcast when status is unchanged', function () { + $order = Order::factory()->create(['status' => 'paid']); + $event = new OrderUpdated($order); + + expect($event->broadcastWhen())->toBeFalse(); +}); + +it('broadcasts when status moves to shipped', function () { + $order = Order::factory()->create(['status' => 'paid']); + $order->update(['status' => 'shipped']); + $event = new OrderUpdated($order); + + expect($event->broadcastWhen())->toBeTrue(); +}); +``` + +## Testing Trait-Captured Context + +Because `ModelEvent` runs `setupEventUser`, `setupEventUrls`, `setupEventChanges`, `setupEventStateable` in its constructor, you can assert against those properties directly. + +```php +it('captures the authenticated user and changed attributes', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $order = Order::factory()->create(['status' => 'paid']); + $order->update(['status' => 'shipped']); + + $event = new OrderUpdated($order); + + expect($event->user->is($user))->toBeTrue() + ->and($event->wasChanged('status'))->toBeTrue() + ->and($event->changedAttributes)->toHaveKey('status', 'shipped'); +}); +``` + +## Testing Channel Authorization + +Channel authorization lives in `routes/channels.php`. Test it with `$this->actingAs(...)` and the `broadcastingAuth` endpoint, or by invoking the closure directly: + +```php +use Illuminate\Support\Facades\Broadcast; + +it('authorizes the owning user for their model channel', function () { + $user = User::factory()->create(); + $order = Order::factory()->for($user)->create(); + + $this->actingAs($user) + ->post('/broadcasting/auth', [ + 'channel_name' => "private-models.{$order->id}", + 'socket_id' => '12345.67890', + ]) + ->assertOk(); +}); +``` + +## BroadcastManager Tests + +```php +use Unusualify\Modularity\Services\BroadcastManager; + +it('builds a broadcast config for a model and event list', function () { + $order = Order::factory()->create(); + + $config = BroadcastManager::forModel($order, [ + OrderCreated::class, + OrderShipped::class, + ]); + + expect($config)->toBeArray() + ->and($config[0])->toHaveKeys(['name', 'type', 'events']) + ->and($config[0]['events'])->toHaveCount(2); +}); +``` + +## Integration Testing with Reverb + +For end-to-end verification against a real Reverb server, run the server on a known port in the test environment and use `pusher-js` (or `laravel-echo`) with a short timeout to subscribe and assert. This is usually overkill — unit-testing the event + manager is enough for CI. + +```bash +# In a test-only terminal +REVERB_PORT=9876 php artisan reverb:start --port=9876 +``` + +## Common Patterns + +| Want to assert... | Use | +|-------------------|-----| +| An event was dispatched | `Event::assertDispatched(EventClass::class)` | +| With specific data | `Event::assertDispatched(EventClass::class, fn($e) => $e->...)` | +| It was **not** dispatched | `Event::assertNotDispatched(EventClass::class)` | +| Exactly N dispatches | `Event::assertDispatchedTimes(EventClass::class, $n)` | +| Payload shape | Construct the event directly and inspect `broadcastWith()` | +| Channel names | Inspect `broadcastOn()` return value | + +## Related + +- [Broadcasting Overview](./overview) — setup and default behaviour +- [ModelEvent](/system-reference/backend/events/model-event) — base class and auto-captured context +- [BroadcastManager](/system-reference/backend/services/broadcast-manager) — service under test above +- [Troubleshooting](./troubleshooting) — when tests pass but the browser doesn't receive events diff --git a/docs/src/pages/guide/broadcasting/troubleshooting.md b/docs/src/pages/guide/broadcasting/troubleshooting.md new file mode 100644 index 000000000..cc498288f --- /dev/null +++ b/docs/src/pages/guide/broadcasting/troubleshooting.md @@ -0,0 +1,228 @@ +--- +sidebarPos: 3 +sidebarTitle: Troubleshooting +outline: deep +--- + +# Broadcasting Troubleshooting + +The most common problems and how to diagnose them. Run through in order. + +## 1. The Echo Listener Never Fires + +**Symptom**: `channel.listen(...)` registered, but the callback never runs even after events dispatch on the server. + +### Did you prefix the event name with `.`? + +Laravel Echo treats names **without** a leading dot as class strings (`App\Events\Foo`), and names **with** a leading dot as custom broadcast names. + +```js +// Wrong — Echo looks for a class, never matches +channel.listen('modularity.model.created', handler) + +// Correct — matches broadcastAs() output +channel.listen('.modularity.model.created', handler) +``` + +### Does the event actually reach Reverb? + +Tail the Reverb server log while dispatching: + +```bash +php artisan reverb:start --debug +``` + +You should see a `Broadcasting event` line for every dispatch. If nothing appears: + +- The event is missing `implements ShouldBroadcast`. +- `broadcastWhen()` is returning `false`. +- The queue is not running (broadcasts go through the queue when `ShouldBroadcast` is combined with `ShouldQueue` or when the default queue is async). Run `php artisan queue:work`. + +### Did you `php artisan config:clear`? + +Broadcasting config is cached. After changing `config/broadcasting.php` or `.env`: + +```bash +php artisan config:clear +php artisan reverb:restart +``` + +--- + +## 2. Private Channel Subscription Fails (403 / `unauthorized`) + +**Symptom**: Browser console shows `Echo: error` on a `private-*` channel. + +### Register the authorization + +Private and presence channels require an entry in `routes/channels.php`: + +```php +use Illuminate\Support\Facades\Broadcast; + +Broadcast::channel('models.{modelId}', function ($user, $modelId) { + return $user !== null; // tighten to real ownership logic +}); +``` + +The channel pattern must match what `broadcastOn()` returns. `new PrivateChannel('models.' . $id)` maps to the `models.{modelId}` pattern. + +### Auth endpoint reachable? + +The frontend hits `POST /broadcasting/auth` by default. Verify: + +- The route is not blocked by CSRF middleware without the correct token (Inertia / Axios default handling usually covers this). +- The session cookie is being sent (check `withCredentials: true` on CORS-bridged setups). +- The user is actually logged in (`Auth::check()` in the channel closure). + +### Wrong channel name casing / prefix + +Echo auto-prefixes private channels with `private-`. In `channels.php` you register **without** the prefix: + +```php +// channels.php +Broadcast::channel('models.{modelId}', /* ... */); // NOT 'private-models.{modelId}' +``` + +But when you inspect the channel name from `BroadcastManager::forModel`, you see `private-models.42` — that's correct for the client side. + +--- + +## 3. Events Arrive But Payload Is Empty / Wrong + +### Public properties missing + +Only **public** properties are serialized into the broadcast payload. A `protected $internalFlag` will not appear. + +### `broadcastWith()` returns the wrong shape + +If you override `broadcastWith()`, its return value **replaces** the default public-property payload. Remember to include any fields the frontend depends on: + +```php +public function broadcastWith(): array +{ + return [ + 'id' => $this->model->id, + 'user' => $this->user, + 'status' => $this->currentStateableState, + ]; +} +``` + +### Changes array empty on `ModelCreated` + +`getChanges()` returns the changes from the **last save**. On `create`, it is usually empty because the model was freshly inserted rather than updated. Use the model itself, not `changedAttributes`, for create events. + +--- + +## 4. Events Dispatch Twice (Or More) + +- Check for **duplicate observers** — if the event is dispatched both in the model's `boot()` and a repository trait, it will fire twice. +- `queue:work` running multiple processes on the same queue connection can re-dispatch retried jobs. Use idempotent listeners or `ShouldBeUnique`. +- Hot-reload tooling (Vite HMR) can re-instantiate Echo on every save, so the **subscriber** side doubles up. In dev, ensure `Echo.leave(...)` is called in Vue `onBeforeUnmount`. + +--- + +## 5. Reverb Connection Refused / TLS Errors + +### Local dev + +```dotenv +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http + +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" +``` + +Echo config must match the scheme: + +```js +new Echo({ + broadcaster: 'reverb', + forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https', + enabledTransports: ['ws', 'wss'], +}) +``` + +### Behind a reverse proxy (nginx / Caddy) + +Add WebSocket upgrade headers: + +```nginx +location /app/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; +} +``` + +### Production + +- Run `reverb:start` under Supervisor / systemd so it restarts on crash. +- Terminate TLS at the proxy; run Reverb on a local HTTP port. +- Set `REVERB_SCHEME=https` so Echo uses `wss`. + +--- + +## 6. `BroadcastManager::forModel` Returns an Empty Array + +Each event class passed to `forModel` must: + +1. Accept `$model` as the first constructor arg. +2. Implement `broadcastOn()` returning `Channel[]`. +3. Optionally implement `broadcastAs()`. + +If one of the event classes errors in its constructor, the whole call can return early. Wrap the call in try/catch during debugging to surface the exception. + +```php +try { + $config = BroadcastManager::forModel($model, $eventClasses); +} catch (\Throwable $e) { + logger()->error('BroadcastManager failed', [ + 'model' => $model->getKey(), + 'error' => $e->getMessage(), + ]); + $config = []; +} +``` + +--- + +## 7. Stateable Context Not Populated + +`$event->hasStateable` is `false` when the model does not use the `HasStateable` trait, or when the trait's state query fails silently. Verify: + +```php +use function Unusualify\Modularity\classHasTrait; +use Unusualify\Modularity\Entities\Traits\HasStateable; + +classHasTrait($order, HasStateable::class); // must be true +``` + +See [ModelEvent / EventStateable](/system-reference/backend/events/traits/event-stateable). + +--- + +## Quick Diagnostic Checklist + +| Check | Command / Action | +|-------|------------------| +| Reverb running | `php artisan reverb:start --debug` | +| Queue running | `php artisan queue:work` | +| Config fresh | `php artisan config:clear` | +| Event implements `ShouldBroadcast` | Class check | +| Echo event name has leading `.` | Client code | +| Channel auth registered | `routes/channels.php` | +| `forceTLS` matches scheme | Echo config vs `REVERB_SCHEME` | +| User authenticated | `Auth::check()` for private channels | + +## Related + +- [Broadcasting Overview](./overview) — setup and default behaviour +- [ModelEvent](/system-reference/backend/events/model-event) — base class reference +- [Testing](./testing) — assert events fire correctly diff --git a/docs/src/pages/guide/commands/Assets/build.md b/docs/src/pages/guide/commands/Assets/build.md deleted file mode 100644 index e36c7101a..000000000 --- a/docs/src/pages/guide/commands/Assets/build.md +++ /dev/null @@ -1,236 +0,0 @@ -# `Build` - -> Build the Modularity assets with custom Vue components - -## Command Information - -- **Signature:** `modularity:build [--noInstall] [--hot] [-w|--watch] [-c|--copyOnly] [-cc|--copyComponents] [-ct|--copyTheme] [-cts|--copyThemeScript] [--theme [THEME]]` -- **Category:** Assets - - -## Examples - -### Basic Usage - -```bash -php artisan modularity:build -``` - -### With Options - -```bash -php artisan modularity:build --noInstall -``` - -```bash -php artisan modularity:build --hot -``` - -```bash -# Using shortcut -php artisan modularity:build -w - -# Using full option name -php artisan modularity:build --watch -``` - -```bash -# Using shortcut -php artisan modularity:build -c - -# Using full option name -php artisan modularity:build --copyOnly -``` - -```bash -# Using shortcut -php artisan modularity:build -cc - -# Using full option name -php artisan modularity:build --copyComponents -``` - -```bash -# Using shortcut -php artisan modularity:build -ct - -# Using full option name -php artisan modularity:build --copyTheme -``` - -```bash -# Using shortcut -php artisan modularity:build -cts - -# Using full option name -php artisan modularity:build --copyThemeScript -``` - -```bash -php artisan modularity:build --theme=THEME -``` - - -`modularity:build` ------------------- - -Build the Modularity assets with custom Vue components - -### Usage - -* `modularity:build [--noInstall] [--hot] [-w|--watch] [-c|--copyOnly] [-cc|--copyComponents] [-ct|--copyTheme] [-cts|--copyThemeScript] [--theme [THEME]]` -* `unusual:build` - -Build the Modularity assets with custom Vue components - -### Options - -#### `--noInstall` - -No install npm packages - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--hot` - -Hot Reload - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--watch|-w` - -Watcher for dev - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--copyOnly|-c` - -Only copy assets - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--copyComponents|-cc` - -Only copy custom components - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--copyTheme|-ct` - -Only copy custom theme - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--copyThemeScript|-cts` - -Only copy custom theme script - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--theme` - -Custom theme name if was worked on - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Assets/dev.md b/docs/src/pages/guide/commands/Assets/dev.md deleted file mode 100644 index cdc9ab4b7..000000000 --- a/docs/src/pages/guide/commands/Assets/dev.md +++ /dev/null @@ -1,116 +0,0 @@ -# `Dev` - -> Hot reload unusual assets with custom Vue component, configuration - -## Command Information - -- **Signature:** `modularity:dev [--noInstall]` -- **Category:** Assets - - -## Examples - -### Basic Usage - -```bash -php artisan modularity:dev -``` - -### With Options - -```bash -php artisan modularity:dev --noInstall -``` - - -`modularity:dev` ----------------- - -Hot reload unusual assets with custom Vue component, configuration - -### Usage - -* `modularity:dev [--noInstall]` -* `unusual:dev` - -Hot reload unusual assets with custom Vue component, configuration - -### Options - -#### `--noInstall` - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Composer/composer-merge.md b/docs/src/pages/guide/commands/Composer/composer-merge.md deleted file mode 100644 index 16bee194e..000000000 --- a/docs/src/pages/guide/commands/Composer/composer-merge.md +++ /dev/null @@ -1,121 +0,0 @@ -# `Composer Merge` - -> Add merge-plugin require pattern for composer-merge-plugin package - -## Command Information - -- **Signature:** `modularity:composer:merge [-p|--production]` -- **Category:** Composer - - -## Examples - -### Basic Usage - -```bash -php artisan modularity:composer:merge -``` - -### With Options - -```bash -# Using shortcut -php artisan modularity:composer:merge -p - -# Using full option name -php artisan modularity:composer:merge --production -``` - - -`modularity:composer:merge` ---------------------------- - -Add merge-plugin require pattern for composer-merge-plugin package - -### Usage - -* `modularity:composer:merge [-p|--production]` - -Add merge-plugin require pattern for composer-merge-plugin package - -### Options - -#### `--production|-p` - -Update Production composer.json file - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Composer/composer-scripts.md b/docs/src/pages/guide/commands/Composer/composer-scripts.md deleted file mode 100644 index c28495a2a..000000000 --- a/docs/src/pages/guide/commands/Composer/composer-scripts.md +++ /dev/null @@ -1,103 +0,0 @@ -# `Composer Scripts` - -> Add modularity composer scripts to composer-dev.json - -## Command Information - -- **Signature:** `modularity:composer:scripts` -- **Category:** Composer - - -## Examples - -### Basic Usage - -```bash -php artisan modularity:composer:scripts -``` - - -`modularity:composer:scripts` ------------------------------ - -Add modularity composer scripts to composer-dev.json - -### Usage - -* `modularity:composer:scripts` -* `unusual:composer:scripts` -* `mod:composer:scripts` - -Add modularity composer scripts to composer-dev.json - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Database/migrate-refresh.md b/docs/src/pages/guide/commands/Database/migrate-refresh.md deleted file mode 100644 index 9b62d8548..000000000 --- a/docs/src/pages/guide/commands/Database/migrate-refresh.md +++ /dev/null @@ -1,111 +0,0 @@ -# `Migrate Refresh` - -> Refresh migrations of the specified module - -## Command Information - -- **Signature:** `modularity:migrate:refresh <module>` -- **Category:** Database - - -## Examples - -### With Arguments - -```bash -php artisan modularity:migrate:refresh MODULE -``` - - -`modularity:migrate:refresh` ----------------------------- - -Refresh migrations of the specified module - -### Usage - -* `modularity:migrate:refresh <module>` - -Refresh migrations of the specified module - -### Arguments - -#### `module` - -Module name. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Database/migrate-rollback.md b/docs/src/pages/guide/commands/Database/migrate-rollback.md deleted file mode 100644 index 1d8f572de..000000000 --- a/docs/src/pages/guide/commands/Database/migrate-rollback.md +++ /dev/null @@ -1,111 +0,0 @@ -# `Migrate Rollback` - -> Rollback migrations of the specified module - -## Command Information - -- **Signature:** `modularity:migrate:rollback <module>` -- **Category:** Database - - -## Examples - -### With Arguments - -```bash -php artisan modularity:migrate:rollback MODULE -``` - - -`modularity:migrate:rollback` ------------------------------ - -Rollback migrations of the specified module - -### Usage - -* `modularity:migrate:rollback <module>` - -Rollback migrations of the specified module - -### Arguments - -#### `module` - -Module name. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Database/migrate.md b/docs/src/pages/guide/commands/Database/migrate.md deleted file mode 100644 index 097b29c44..000000000 --- a/docs/src/pages/guide/commands/Database/migrate.md +++ /dev/null @@ -1,111 +0,0 @@ -# `Migrate` - -> Migrate the specified module - -## Command Information - -- **Signature:** `modularity:migrate <module>` -- **Category:** Database - - -## Examples - -### With Arguments - -```bash -php artisan modularity:migrate MODULE -``` - - -`modularity:migrate` --------------------- - -Migrate the specified module - -### Usage - -* `modularity:migrate <module>` - -Migrate the specified module - -### Arguments - -#### `module` - -Module name. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/create-input-hydrate.md b/docs/src/pages/guide/commands/Generators/create-input-hydrate.md deleted file mode 100644 index 5c704bf88..000000000 --- a/docs/src/pages/guide/commands/Generators/create-input-hydrate.md +++ /dev/null @@ -1,112 +0,0 @@ -# `Create Input Hydrate` - -> Create Hydrate Input Class. - -## Command Information - -- **Signature:** `modularity:create:input:hydrate <name>` -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:create:input:hydrate NAME -``` - - -`modularity:create:input:hydrate` ---------------------------------- - -Create Hydrate Input Class. - -### Usage - -* `modularity:create:input:hydrate <name>` -* `mod:c:input:hydrate` - -Create Hydrate Input Class. - -### Arguments - -#### `name` - -The name of theme to be created. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/create-model-trait.md b/docs/src/pages/guide/commands/Generators/create-model-trait.md deleted file mode 100644 index cd9a52051..000000000 --- a/docs/src/pages/guide/commands/Generators/create-model-trait.md +++ /dev/null @@ -1,110 +0,0 @@ -# `Create Model Trait` - -> Create a Model trait - -## Command Information - -- **Signature:** `modularity:create:model:trait <name>` -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:create:model:trait NAME -``` - - -`modularity:create:model:trait` -------------------------------- - -Create a Model trait - -### Usage - -* `modularity:create:model:trait <name>` -* `mod:c:model:trait` - -Create a Model trait - -### Arguments - -#### `name` - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/create-repository-trait.md b/docs/src/pages/guide/commands/Generators/create-repository-trait.md deleted file mode 100644 index 3500c040f..000000000 --- a/docs/src/pages/guide/commands/Generators/create-repository-trait.md +++ /dev/null @@ -1,110 +0,0 @@ -# `Create Repository Trait` - -> Create a Repository trait - -## Command Information - -- **Signature:** `modularity:create:repository:trait <name>` -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:create:repository:trait NAME -``` - - -`modularity:create:repository:trait` ------------------------------------- - -Create a Repository trait - -### Usage - -* `modularity:create:repository:trait <name>` -* `mod:c:repo:trait` - -Create a Repository trait - -### Arguments - -#### `name` - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/create-vue-input.md b/docs/src/pages/guide/commands/Generators/create-vue-input.md deleted file mode 100644 index 2cf96fc45..000000000 --- a/docs/src/pages/guide/commands/Generators/create-vue-input.md +++ /dev/null @@ -1,113 +0,0 @@ -# `Make Vue Input` - -> Create Vue Input Component. - -## Command Information - -- **Signature:** `modularity:make:vue:input <name>` -- **Alias:** `modularity:make:vue:input` (deprecated, use `make:vue:input`) -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:make:vue:input NAME -``` - - -`modularity:make:vue:input` ------------------------------ - -Create Vue Input Component. - -### Usage - -* `modularity:make:vue:input <name>` -* `mod:c:vue:input` - -Create Vue Input Component. - -### Arguments - -#### `name` - -The name of the component to be created. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/create-vue-test.md b/docs/src/pages/guide/commands/Generators/create-vue-test.md deleted file mode 100644 index 2aa71f2e4..000000000 --- a/docs/src/pages/guide/commands/Generators/create-vue-test.md +++ /dev/null @@ -1,160 +0,0 @@ -# `Make Vue Test` - -> Create a test file for vue features or components - -## Command Information - -- **Signature:** `modularity:make:vue:test [--importDir] [-F|--force] [--] [<name> [<type>]]` -- **Alias:** `modularity:make:vue:test` (deprecated, use `make:vue:test`) -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:make:vue:test NAME TYPE -``` - -### With Options - -```bash -php artisan modularity:make:vue:test --importDir -``` - -```bash -# Using shortcut -php artisan modularity:make:vue:test -F - -# Using full option name -php artisan modularity:make:vue:test --force -``` - -### Common Combinations - -```bash -php artisan modularity:make:vue:test NAME -``` - -`modularity:make:vue:test` ----------------------------- - -Create a test file for vue features or components - -### Usage - -* `modularity:make:vue:test [--importDir] [-F|--force] [--] [<name> [<type>]]` -* `mod:c:vue:test` - -Create a test file for vue features or components - -### Arguments - -#### `name` - -The name of test will be used. - -* Is required: no -* Is array: no -* Default: `NULL` - -#### `type` - -The type of test. - -* Is required: no -* Is array: no -* Default: `NULL` - -### Options - -#### `--importDir` - -The subfolder for importing. - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--force|-F` - -Force the operation to run when the route files already exist. - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/generate-command-docs.md b/docs/src/pages/guide/commands/Generators/generate-command-docs.md deleted file mode 100644 index 4f037557a..000000000 --- a/docs/src/pages/guide/commands/Generators/generate-command-docs.md +++ /dev/null @@ -1,135 +0,0 @@ -# `Generate Command Docs` - -> Extract Laravel Console Documentation - -## Command Information - -- **Signature:** `modularity:generate:command:docs [--output [OUTPUT]] [-f|--force]` -- **Category:** Generators - - -## Examples - -### Basic Usage - -```bash -php artisan modularity:generate:command:docs -``` - -### With Options - -```bash -php artisan modularity:generate:command:docs --output=OUTPUT -``` - -```bash -# Using shortcut -php artisan modularity:generate:command:docs -f - -# Using full option name -php artisan modularity:generate:command:docs --force -``` - - -`modularity:generate:command:docs` ----------------------------------- - -Extract Laravel Console Documentation - -### Usage - -* `modularity:generate:command:docs [--output [OUTPUT]] [-f|--force]` - -Extract Laravel Console Documentation - -### Options - -#### `--output` - -Output directory for markdown files - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` - -#### `--force|-f` - -Force overwrite existing files - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/make-controller.md b/docs/src/pages/guide/commands/Generators/make-controller.md deleted file mode 100644 index 5da5eae22..000000000 --- a/docs/src/pages/guide/commands/Generators/make-controller.md +++ /dev/null @@ -1,140 +0,0 @@ -# `Make Controller` - -> Create Controller with repository for specified module. - -## Command Information - -- **Signature:** `modularity:make:controller [--example [EXAMPLE]] [--] <module> <name>` -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:make:controller MODULE NAME -``` - -### With Options - -```bash -php artisan modularity:make:controller --example=EXAMPLE -``` - -### Common Combinations - -```bash -php artisan modularity:make:controller MODULE -``` - -`modularity:make:controller` ----------------------------- - -Create Controller with repository for specified module. - -### Usage - -* `modularity:make:controller [--example [EXAMPLE]] [--] <module> <name>` - -Create Controller with repository for specified module. - -### Arguments - -#### `module` - -The name of module will be used. - -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `name` - -The name of the controller class. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--example` - -An example option. - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/make-request.md b/docs/src/pages/guide/commands/Generators/make-request.md deleted file mode 100644 index 49be8c7e2..000000000 --- a/docs/src/pages/guide/commands/Generators/make-request.md +++ /dev/null @@ -1,140 +0,0 @@ -# `Make Request` - -> Create form request for specified module. - -## Command Information - -- **Signature:** `modularity:make:request [--rules [RULES]] [--] <module> <request>` -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:make:request MODULE REQUEST -``` - -### With Options - -```bash -php artisan modularity:make:request --rules=RULES -``` - -### Common Combinations - -```bash -php artisan modularity:make:request MODULE -``` - -`modularity:make:request` -------------------------- - -Create form request for specified module. - -### Usage - -* `modularity:make:request [--rules [RULES]] [--] <module> <request>` - -Create form request for specified module. - -### Arguments - -#### `module` - -The name of module will be used. - -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `request` - -The name of the request class. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--rules` - -The validation rules. - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/Generators/make-theme.md b/docs/src/pages/guide/commands/Generators/make-theme.md deleted file mode 100644 index aabeb8d0e..000000000 --- a/docs/src/pages/guide/commands/Generators/make-theme.md +++ /dev/null @@ -1,136 +0,0 @@ -# `Make Theme` - -> Generalize a theme. - -## Command Information - -- **Signature:** `modularity:make:theme [-f|--force] [--] <name>` -- **Category:** Generators - - -## Examples - -### With Arguments - -```bash -php artisan modularity:make:theme NAME -``` - -### With Options - -```bash -# Using shortcut -php artisan modularity:make:theme -f - -# Using full option name -php artisan modularity:make:theme --force -``` - -### Common Combinations - -```bash -php artisan modularity:make:theme NAME -``` - -`modularity:make:theme` ------------------------ - -Generalize a theme. - -### Usage - -* `modularity:make:theme [-f|--force] [--] <name>` - -Generalize a theme. - -### Arguments - -#### `name` - -The name of custom theme to be generalized. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--force|-f` - -Force the operation to run when the route files already exist. - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/get-version.md b/docs/src/pages/guide/commands/get-version.md deleted file mode 100644 index 385d08649..000000000 --- a/docs/src/pages/guide/commands/get-version.md +++ /dev/null @@ -1,122 +0,0 @@ -# `Get Version` - -> Get Version of a Package - -## Command Information - -- **Signature:** `modularity:get:version [-p|--package [PACKAGE]]` -- **Category:** Other - - -## Examples - -### Basic Usage - -```bash -php artisan modularity:get:version -``` - -### With Options - -```bash -# Using shortcut -php artisan modularity:get:version -p PACKAGE - -# Using full option name -php artisan modularity:get:version --package=PACKAGE -``` - - -`modularity:get:version` ------------------------- - -Get Version of a Package - -### Usage - -* `modularity:get:version [-p|--package [PACKAGE]]` -* `mod:g:ver` - -Get Version of a Package - -### Options - -#### `--package|-p` - -The package - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/index.md b/docs/src/pages/guide/commands/index.md deleted file mode 100644 index ebab514b7..000000000 --- a/docs/src/pages/guide/commands/index.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -sidebarPos: 0 -sidebarTitle: Commands Overview ---- - -# Commands Overview - -Modularity provides Artisan commands for scaffolding, building, and managing modules. Commands are organized by category. - -## Categories - -| Category | Description | -|----------|-------------| -| **Assets** | Build and dev for frontend assets | -| **Database** | Migrations and rollbacks | -| **Setup** | Installation and development setup | -| **Generators** | Scaffold models, controllers, routes, hydrates, Vue inputs | -| **Module** | Route enable/disable, fix, remove module | -| **Composer** | Composer merge and scripts | - -## Quick Links - -- **Assets**: [build](/guide/commands/Assets/build), [dev](/guide/commands/Assets/dev) -- **Database**: [migrate](/guide/commands/Database/migrate), [migrate-refresh](/guide/commands/Database/migrate-refresh), [migrate-rollback](/guide/commands/Database/migrate-rollback) -- **Setup**: [install](/guide/commands/Setup/install), [setup-development](/guide/commands/Setup/setup-development) -- **Generators**: make:model, make:controller, make:route, make:repository, create-input-hydrate, create-vue-input, etc. -- **Module**: [route:enable](/guide/commands/route-enable), [route:disable](/guide/commands/route-disable), [fix-module](/guide/commands/fix-module), [remove-module](/guide/commands/remove-module) - -See [Backend](/system-reference/backend#console-commands) for a full command list. diff --git a/docs/src/pages/guide/commands/refresh.md b/docs/src/pages/guide/commands/refresh.md deleted file mode 100644 index 8b251d130..000000000 --- a/docs/src/pages/guide/commands/refresh.md +++ /dev/null @@ -1,101 +0,0 @@ -# `Refresh` - -> Move new unusual front sources - -## Command Information - -- **Signature:** `modularity:refresh` -- **Category:** Other - - -## Examples - -### Basic Usage - -```bash -php artisan modularity:refresh -``` - - -`modularity:refresh` --------------------- - -Move new unusual front sources - -### Usage - -* `modularity:refresh` - -Move new unusual front sources - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/remove-module.md b/docs/src/pages/guide/commands/remove-module.md deleted file mode 100644 index fb035879b..000000000 --- a/docs/src/pages/guide/commands/remove-module.md +++ /dev/null @@ -1,112 +0,0 @@ -# `Remove Module` - -> Remove completely a module. - -## Command Information - -- **Signature:** `modularity:remove:module <module>` -- **Category:** Other - - -## Examples - -### With Arguments - -```bash -php artisan modularity:remove:module MODULE -``` - - -`modularity:remove:module` --------------------------- - -Remove completely a module. - -### Usage - -* `modularity:remove:module <module>` -* `m:r:m` -* `mod:r:module` -* `unusual:remove:module` - -Remove completely a module. - -### Arguments - -#### `module` - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/replace-regex.md b/docs/src/pages/guide/commands/replace-regex.md deleted file mode 100644 index 4e25951fd..000000000 --- a/docs/src/pages/guide/commands/replace-regex.md +++ /dev/null @@ -1,171 +0,0 @@ -# `Replace Regex` - -> Replace matches - -## Command Information - -- **Signature:** `modularity:replace:regex [-d|--directory [DIRECTORY]] [-p|--pretend] [--] <path> <pattern> <data>` -- **Category:** Other - - -## Examples - -### With Arguments - -```bash -php artisan modularity:replace:regex PATH PATTERN DATA -``` - -### With Options - -```bash -# Using shortcut -php artisan modularity:replace:regex -d DIRECTORY - -# Using full option name -php artisan modularity:replace:regex --directory=DIRECTORY -``` - -```bash -# Using shortcut -php artisan modularity:replace:regex -p - -# Using full option name -php artisan modularity:replace:regex --pretend -``` - -### Common Combinations - -```bash -php artisan modularity:replace:regex PATH -``` - -`modularity:replace:regex` --------------------------- - -Replace matches - -### Usage - -* `modularity:replace:regex [-d|--directory [DIRECTORY]] [-p|--pretend] [--] <path> <pattern> <data>` -* `mod:replace:regex` - -Replace matches - -### Arguments - -#### `path` - -The path to the files - -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `pattern` - -The pattern to replace - -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `data` - -The data to replace - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--directory|-d` - -The directory pattern - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` - -#### `--pretend|-p` - -Dump files that would be modified - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/commands/route-disable.md b/docs/src/pages/guide/commands/route-disable.md deleted file mode 100644 index fe58d0747..000000000 --- a/docs/src/pages/guide/commands/route-disable.md +++ /dev/null @@ -1,119 +0,0 @@ -# `Route Disable` - -> Disable the specified module's route. - -## Command Information - -- **Signature:** `modularity:route:disable <module> <route>` -- **Category:** Other - - -## Examples - -### With Arguments - -```bash -php artisan modularity:route:disable MODULE ROUTE -``` - - -`modularity:route:disable` --------------------------- - -Disable the specified module's route. - -### Usage - -* `modularity:route:disable <module> <route>` - -Disable the specified module's route. - -### Arguments - -#### `module` - -Module name. - -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `route` - -Route name. - -* Is required: yes -* Is array: no -* Default: `NULL` - -### Options - -#### `--help|-h` - -Display help for the given command. When no command is given display help for the list command - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--quiet|-q` - -Do not output any message - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--verbose|-v|-vv|-vvv` - -Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--version|-V` - -Display this application version - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--ansi|--no-ansi` - -Force (or disable --no-ansi) ANSI output - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: yes -* Default: `NULL` - -#### `--no-interaction|-n` - -Do not ask any interactive question - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - -#### `--env` - -The environment the command should run under - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` \ No newline at end of file diff --git a/docs/src/pages/guide/components/alert.md b/docs/src/pages/guide/components/alert.md new file mode 100644 index 000000000..108f71489 --- /dev/null +++ b/docs/src/pages/guide/components/alert.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 6 +sidebarTitle: Alert +--- +# Alert + +The `ue-alert` component is a global snackbar notification tied to the Vuex alert store. It is mounted once at the app root and reacts to any commit to `ALERT.SET_ALERT`. You never place it directly inside page templates — it is included automatically by the Modularous layout. + +## How It Works + +Any service, form, or component can trigger a notification by committing to the store: + +```js +this.$store.commit(ALERT.SET_ALERT, { + type: 'success', + message: 'Record saved successfully.', +}) +``` + +The alert component picks up the commit and displays the snackbar at the configured location with the given type and message. + +## Alert Types + +| Type | Color | Default message key | +|------|-------|---------------------| +| `success` | green | `messages.success` | +| `error` | red | `messages.error` | +| `warning` | orange | `messages.warning` | +| `info` | blue | `messages.info` | + +Default messages are resolved through the i18n system (`$t('messages.<type>')`). Pass a `message` string to override. + +## Store Payload + +| Key | Type | Description | +|-----|------|-------------| +| `type` | `String` | One of `success`, `error`, `warning`, `info` | +| `message` | `String` | Custom message text. Falls back to the i18n default if omitted | +| `location` | `String` | Vuetify snackbar `location` prop (e.g. `'top'`, `'bottom right'`). Defaults to `'bottom'` | + +## Programmatic Usage + +```js +// From inside a Vue component +import { ALERT } from '@/store/mutations/index' + +// Success +this.$store.commit(ALERT.SET_ALERT, { type: 'success' }) + +// Error with custom message +this.$store.commit(ALERT.SET_ALERT, { + type: 'error', + message: 'Something went wrong. Please try again.', +}) + +// Show at top of viewport +this.$store.commit(ALERT.SET_ALERT, { + type: 'info', + message: 'New update available.', + location: 'top', +}) +``` + +::: info Auto-dismissal +The snackbar auto-dismisses after 3 000 ms by default. The timeout is not currently exposed as a store payload key; use the component's `open()` method if you need a custom timeout from inside a child component. +::: diff --git a/docs/src/pages/guide/components/assignee-details.md b/docs/src/pages/guide/components/assignee-details.md new file mode 100644 index 000000000..b56c35ea3 --- /dev/null +++ b/docs/src/pages/guide/components/assignee-details.md @@ -0,0 +1,59 @@ +--- +sidebarPos: 50 +sidebarTitle: Assignee & Assignment +--- +# Assignee & Assignment + +These two components handle task/assignment UI patterns: viewing assignment details and creating/editing an assignment through a modal form. + +## `ue-assignee-details` + +Renders a compact list item showing the current assignee's avatar and name. Clicking it opens a popover card with the full assignment summary, description, and attachment list. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `assignment` | `Object` | `{}` | Raw assignment record (due date, description, etc.) | +| `formattedAssignment` | `Object` | `{}` | Pre-formatted version for display (`prependAvatar`, `assigneeName`, `subDescription`) | +| `isAssignee` | `Boolean` | `false` | Whether the current user is the assignee (controls visibility) | +| `isAuthorized` | `Boolean` | `false` | Whether the current user can view/edit the assignment | +| `attachments` | `Array` | `[]` | File attachments linked to the assignment | +| `attachmentsLoading` | `Boolean` | `false` | Show a loading indicator on the attachments section | +| `filepond` | `Object` | `null` | FilePond config for uploading new attachments | + +### Events + +| Event | Description | +|-------|-------------| +| `update:attachments` | Emitted when attachments change | +| `update:attachmentsLoading` | Emitted when attachment loading state changes | +| `click:complete` | Emitted when the "Mark Complete" action is triggered | +| `click:save` | Emitted when the "Save" action is triggered | + +--- + +## `ue-assignment-modal` + +Renders a modal form for creating or editing an assignment. Includes fields for assignee, due date, description, and preliminary tasks. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Object` | `{}` | Controls modal open state via `v-model` | +| `loading` | `Boolean` | `false` | Show a loading state on submit buttons | +| `form` | `Object` | `{}` | Initial form model (assignee_id, due_at, description, preliminaries) | +| `users` | `Array` | `[]` | List of available assignee options | +| `filepond` | `Object` | `{}` | FilePond config for file attachments | +| `variant` | `String` | `'outlined'` | Vuetify variant for form inputs | +| `disabled` | `Boolean` | `false` | Disable all form inputs | +| `minDueDays` | `Number` | `0` | Minimum number of days in the future for the due date | + +### Events + +| Event | Description | +|-------|-------------| +| `update:modelValue` | Emitted to close/open the modal | +| `update:form` | Emitted when form fields change | +| `submit` | Emitted on form submission | diff --git a/docs/src/pages/guide/components/auth.md b/docs/src/pages/guide/components/auth.md new file mode 100644 index 000000000..781abdaf6 --- /dev/null +++ b/docs/src/pages/guide/components/auth.md @@ -0,0 +1,70 @@ +--- +sidebarPos: 20 +sidebarTitle: Auth +--- +# Auth + +`ue-auth` is the full-page authentication layout component. It renders the branded card, logo, optional banner, the form slot, and an optional divider + bottom slot (e.g. for OAuth buttons). The global `ue-alert` and `ue-dynamic-modal` are also mounted here so they're available on auth pages. + +## Usage + +```html +<ue-auth> + <!-- ue-form for login/register goes in the default slot --> + <ue-form :model-value="form" :schema="schema" action-url="/login" has-submit /> +</ue-auth> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `logoLightSymbol` | `String` | `'main-logo-light'` | SVG sprite symbol used for the logo inside the card | +| `logoSymbol` | `String` | `'main-logo-dark'` | Dark-variant symbol (reserved) | +| `noDivider` | `Boolean\|Number` | `false` | Hide the divider between form and `bottom` slot | +| `noSecondSection` | `Boolean\|Number` | `false` | Hide the banner (`description` slot) above the form | +| `slots` | `Object` | `{}` | Reserved for server-side slot injection | + +## Slots + +| Slot | Description | +|------|-------------| +| `default` | The auth form — place `ue-form` here | +| `description` | Banner content shown above the card's form sheet | +| `cardTop` | Injected at the very top of the card, before the banner | +| `bottom` | Content below the divider — typically OAuth / SSO buttons | + +## Layout Config + +Card width per breakpoint can be customised via the global `window.__MODULARITY_AUTH_CONFIG__` object: + +```js +window.__MODULARITY_AUTH_CONFIG__ = { + formWidth: { + xs: '90vw', + sm: '420px', + md: '460px', + lg: '500px', + }, + dividerText: 'or continue with', +} +``` + +## Example — Login Page + +```html +<ue-auth> + <template #description> + <h2 class="text-h5 font-weight-bold">Welcome back</h2> + <p class="text-body-2 text-medium-emphasis">Sign in to your account</p> + </template> + + <ue-form :model-value="credentials" :schema="loginSchema" action-url="/login" has-submit button-text="Sign In" /> + + <template #bottom> + <v-btn block variant="outlined" prepend-icon="mdi-google" href="/auth/google"> + Continue with Google + </v-btn> + </template> +</ue-auth> +``` diff --git a/docs/src/pages/guide/components/blocks.md b/docs/src/pages/guide/components/blocks.md new file mode 100644 index 000000000..5d646a330 --- /dev/null +++ b/docs/src/pages/guide/components/blocks.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 24 +sidebarTitle: Blocks +--- +# Blocks + +`ue-blocks` renders a collection of `ue-recursive-stuff` configuration objects side-by-side in a `v-row`. It is the top-level block container used by Modularous index and dashboard pages to lay out configurable content areas. + +## Usage + +```html +<ue-blocks :items="pageBlocks" /> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `items` | `Object` | `{}` | Map of block configurations. Each value is a `ue-recursive-stuff` configuration object | + +## Example + +```php +@php + $blocks = [ + 'metrics' => [ + 'tag' => 'ue-metrics', + 'attributes' => [ + 'title' => 'Overview', + 'items' => $metricsData, + ], + ], + 'list' => [ + 'tag' => 'ue-list-section', + 'attributes' => [ + 'items' => $recentItems, + 'item-fields' => ['name', 'status'], + ], + ], + ]; +@endphp + +<ue-blocks :items='@json($blocks)' /> +``` + +::: tip +`ue-blocks` wraps each block in a `v-row` but does not control column widths internally — the block's own `tag` is responsible for layout. For grid control, use `ue-metric-groups` or wrap blocks in `v-col` manually. +::: diff --git a/docs/src/pages/guide/components/board-information-plus.md b/docs/src/pages/guide/components/board-information-plus.md new file mode 100644 index 000000000..b1f6b9a44 --- /dev/null +++ b/docs/src/pages/guide/components/board-information-plus.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 35 +sidebarTitle: Board Information Plus +--- +# Board Information Plus + +`ue-board-information-plus` renders an "At a Glance" dashboard card grid. Each card displays a labelled statistic with an icon. + +## Usage + +```html +<ue-board-information-plus :cards="glanceCards" /> +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `cards` | `Array` | Array of card objects (see Card Shape below) | +| `container` | `Object` | Styles applied to the outer container card (defaults: `{ color: '#F8F8FF', elevation: 10, class: 'px-6 py-5' }`) | +| `cardAttribute` | `Object` | Shared visual attributes for all inner cards (variant, border radius, title/info styles — see defaults in source) | + +## Card Shape + +Each item in the `cards` array supports: + +| Key | Type | Description | +|-----|------|-------------| +| `title` | `String` | Stat label (passed through `$t()` for i18n) | +| `data` | `Object` | Object with an `items` key holding the display value (e.g. `{ items: 42 }`) | +| `flex` | `Number` | Column width in a 12-column grid (e.g. `6` for half-width) | +| `icon` | `String` | MDI icon name | +| `iconColor` | `String` | Icon colour | +| `iconBackground` | `String` | CSS colour for the circular icon container | +| `iconSize` | `Number\|String` | Icon size | +| `infoColor` | `String` | Colour of the stat value text | + +## Example + +```js +const glanceCards = [ + { title: 'Active Users', data: { items: 142 }, flex: 6, icon: 'mdi-account-group', iconColor: 'white', iconBackground: '#4CAF50' }, + { title: 'Pending Tasks', data: { items: 8 }, flex: 6, icon: 'mdi-clipboard-list', iconColor: 'white', iconBackground: '#FF9800' }, +] +``` diff --git a/docs/src/pages/guide/components/btn.md b/docs/src/pages/guide/components/btn.md new file mode 100644 index 000000000..c581b59ca --- /dev/null +++ b/docs/src/pages/guide/components/btn.md @@ -0,0 +1,57 @@ +--- +sidebarPos: 7 +sidebarTitle: Btn +--- +# Btn + +The `ue-btn` component is a thin wrapper around Vuetify's `v-btn`. It forwards all Vuetify button props via `$bindAttributes()` and adds a built-in 5-second debounce on click to prevent double-submission. + +## Usage + +```html +<ue-btn color="primary" variant="elevated" @click="handleSave"> + Save +</ue-btn> +``` + +## Props + +`ue-btn` accepts all [Vuetify `v-btn` props](https://vuetifyjs.com/en/api/v-btn/#props) plus the following: + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `revealed` | `Boolean` | `false` | Reserved for future use (toggling a revealed/hidden state) | + +## Click Debounce + +After each click, `ue-btn` automatically disables itself for **5 seconds** and re-enables. This prevents accidental double-clicks on form submission buttons without any extra logic in the parent. + +```html +<!-- The button is disabled for 5 s after each click automatically --> +<ue-btn color="secondary" @click="submitOrder"> + Place Order +</ue-btn> +``` + +::: warning Disabled Override +If you need to control `disabled` externally (e.g. while an async request is in flight), bind `:disabled="loading"` — this overrides the internal debounce state. +::: + +## Common Vuetify Props + +| Prop | Example values | Description | +|------|---------------|-------------| +| `color` | `'primary'`, `'error'`, `'#ff5722'` | Button color | +| `variant` | `'elevated'`, `'flat'`, `'tonal'`, `'outlined'`, `'text'`, `'plain'` | Visual style | +| `size` | `'x-small'`, `'small'`, `'default'`, `'large'`, `'x-large'` | Button size | +| `rounded` | `true`, `'xs'`, `'sm'`, `'lg'`, `'xl'`, `'pill'` | Border radius | +| `density` | `'default'`, `'comfortable'`, `'compact'` | Vertical density | +| `prepend-icon` | `'mdi-plus'` | Icon before label | +| `append-icon` | `'mdi-chevron-right'` | Icon after label | +| `icon` | `'mdi-delete'` | Icon-only mode (square button) | +| `loading` | `true` / `false` | Shows a loading spinner | +| `disabled` | `true` / `false` | Disables the button | +| `href` | `'/path'` | Renders as an anchor | +| `block` | `true` / `false` | Full-width button | + +See the full API at [Vuetify v-btn](https://vuetifyjs.com/en/api/v-btn/). diff --git a/docs/src/pages/guide/components/chat-message.md b/docs/src/pages/guide/components/chat-message.md new file mode 100644 index 000000000..2fc7a5c97 --- /dev/null +++ b/docs/src/pages/guide/components/chat-message.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 47 +sidebarTitle: Chat Message +--- +# Chat Message + +`ue-chat-message` renders a single chat message bubble with an avatar, sender name, timestamp, message content (with URL linkification), and optional attachment previews. Messages from the current user are displayed right-aligned with `reverse`. + +## Usage + +```html +<ue-chat-message + v-model="message" + update-endpoint="/api/messages/:id" + :reverse="message.user_id === currentUserId" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Object` | required | Message object (see Message Shape below) | +| `updateEndpoint` | `String` | required | API endpoint called when the user toggles starring or pinning | +| `reverse` | `Boolean` | `false` | Align the bubble to the right (current user's own messages) | +| `avatarSize` | `Number` | `40` | Avatar size in pixels on desktop | +| `mobileAvatarSize` | `Number` | `20` | Avatar size in pixels on mobile | +| `noStarring` | `Boolean` | `false` | Hide the star/unstar icon | +| `noPinning` | `Boolean` | `false` | Hide the pin/unpin icon | +| `contentTruncateLength` | `Number` | `50` | Characters shown before a "Show more" toggle appears | + +## Message Shape + +```js +{ + id: 1, + content: 'Hello, world!', + created_at: '2024-01-15T10:30:00Z', + is_read: false, + is_starred: false, + is_pinned: false, + user_profile: { + name: 'Alice', + avatar_url: '/avatars/alice.jpg', + }, + attachments: [], // array of filepond file objects +} +``` + +## Behaviour + +- Long messages are truncated to `contentTruncateLength` characters with a "Show more / Show less" toggle. +- URLs in message content are auto-linkified via `ue-well-print`. +- Attachments are rendered with `ue-filepond-preview` using `image-size="24"`. +- Unread messages (where `is_read` is falsy and `reverse` is false) receive a visual highlight. +- Starring and pinning patch the message via `updateEndpoint` and update the `modelValue` optimistically. diff --git a/docs/src/pages/guide/components/collapsible.md b/docs/src/pages/guide/components/collapsible.md new file mode 100644 index 000000000..2bf54104e --- /dev/null +++ b/docs/src/pages/guide/components/collapsible.md @@ -0,0 +1,67 @@ +--- +sidebarPos: 12 +sidebarTitle: Collapsible +--- +# Collapsible + +The `ue-collapsible` component wraps any content in a togglable panel with a clickable header and Vuetify's expand transition. It supports `v-model` for controlled open/close state. + +## Usage + +```html +<ue-collapsible title="Advanced Options"> + <p>Hidden content shown when expanded.</p> +</ue-collapsible> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Boolean` | `false` | Controls open/closed state (use with `v-model`) | +| `title` | `String` | `''` | Header text. Can be replaced by the `title` slot | +| `bordered` | `Boolean` | `false` | Add a border and rounded corners around the component | +| `elevated` | `Boolean` | `false` | Add a subtle box shadow | +| `dense` | `Boolean` | `false` | Apply compact spacing | +| `noHeaderBackground` | `Boolean` | `false` | Remove the default faint header background | +| `noCollapse` | `Boolean` | `false` | Disable toggling — content is always visible | +| `horizontalPadding` | `Number` | `4` | Vuetify spacing scale for horizontal padding | +| `verticalPadding` | `Number` | `3` | Vuetify spacing scale for vertical padding | +| `color` | `String` | `'primary'` | Reserved for future use | + +## Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `Boolean` | Emitted when open state changes | +| `open` | — | Emitted when the panel opens | +| `close` | — | Emitted when the panel closes | + +## Slots + +| Slot | Description | +|------|-------------| +| `default` | The collapsible body content | +| `title` | Replaces the header text with custom markup | + +## Examples + +```html +<!-- Controlled with v-model --> +<ue-collapsible v-model="showDetails" title="Details" bordered> + <ue-property-list :data="item" /> +</ue-collapsible> + +<!-- Always open (no-collapse) --> +<ue-collapsible title="Notes" no-collapse elevated> + <p>{{ item.notes }}</p> +</ue-collapsible> + +<!-- Custom title slot --> +<ue-collapsible bordered> + <template #title> + <v-icon class="mr-2">mdi-filter</v-icon> Filters + </template> + <!-- filter inputs --> +</ue-collapsible> +``` diff --git a/docs/src/pages/guide/components/configurable-card.md b/docs/src/pages/guide/components/configurable-card.md new file mode 100644 index 000000000..c606e539b --- /dev/null +++ b/docs/src/pages/guide/components/configurable-card.md @@ -0,0 +1,86 @@ +--- +sidebarPos: 19 +sidebarTitle: Configurable Card +--- +# Configurable Card + +`ue-configurable-card` is a multi-column card layout engine. It splits an `items` object into equal-width segments separated by dividers, with an optional actions column appended at the end. Each segment renders as `ue-property-list` by default and can be overridden with a named slot. + +## Usage + +```html +<ue-configurable-card + title="Order Summary" + :items="{ info: orderInfo, shipping: shippingInfo }" + :actions="[{ icon: 'mdi-pencil', color: 'primary', onClick: editOrder }]" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `items` | `Object\|Array` | required | Segments to render. Each key/index becomes one column | +| `title` | `String` | `''` | Card title | +| `titleColor` | `String` | — | Title text color | +| `actions` | `Array` | `[]` | Action button definitions appended as the last column | +| `noActions` | `Boolean` | `false` | Suppress the actions column even if `actions` is non-empty | +| `hideSeparator` | `Boolean` | `false` | Remove vertical dividers between columns | +| `maxSegments` | `Number` | `null` | Cap the number of visible segments (1–12) | +| `colRatios` | `Array` | `[]` | Flex ratios for each column, e.g. `[2, 1, 1]` | +| `columnStyles` | `Object` | `{}` | Inline style overrides keyed by column index, e.g. `{ 0: 'flex-basis: 40%' }` | +| `columnClasses` | `Object` | `{}` | CSS class overrides keyed by column index | +| `colPaddingX` | `Number\|String` | `2` | Horizontal padding (Vuetify scale) for all columns | +| `colPaddingY` | `Number\|String` | — | Vertical padding for all columns | +| `rowMarginY` | `Number\|String` | `4` | Vertical margin of the columns row | +| `rowMarginX` | `Number\|String` | — | Horizontal margin of the columns row | +| `rowMinHeight` | `String` | `null` | Minimum height of the row element | +| `alignCenterColumns` | `Boolean` | `false` | Vertically center the content inside each column | +| `justifyCenterColumns` | `Boolean` | `false` | Horizontally center the content inside non-first columns | +| `actionIconSize` | `String` | `'medium'` | Size of action icon buttons | +| `actionIconMinWidth` | `Number` | `44` | Minimum width (px) of action buttons | +| `actionIconMinHeight` | `Number` | `44` | Minimum height (px) of action buttons | +| `mobileBreakpoint` | `String` | `'md'` | Breakpoint below which columns stack vertically | +| `mobileRowGap` | `Number\|String` | `4` | Gap between stacked columns on mobile | +| `mobileColPaddingX` | `Number\|String` | `0` | Horizontal padding per column on mobile | +| `mobileColPaddingY` | `Number\|String` | `0` | Vertical padding per column on mobile | + +## Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `segment.1`, `segment.2`, … | `{data, actions, actionProps}` | Override content for column N (1-based) | +| `segment.actions` | `{data, actions, actionProps}` | Override the actions column | +| `title` | — | Replace the entire title row | + +## Segment Data Format + +Each segment value can be: +- **Object** — rendered as `ue-property-list` +- **Array** — each element becomes a property-list row (single-value pairs) +- **Primitive** — rendered via `ue-dynamic-component-renderer` + +## Examples + +```html +<!-- 3-column contact card --> +<ue-configurable-card + title="Contact" + :items="{ + personal: { Name: 'Alice', Age: 32 }, + contact: { Email: 'alice@example.com', Phone: '+1 555 000' }, + address: { City: 'Istanbul', Country: 'Turkey' }, + }" + :col-ratios="[2, 2, 1]" +/> + +<!-- Custom slot override --> +<ue-configurable-card :items="cardData" :actions="rowActions"> + <template #segment.1="{ data }"> + <div class="d-flex flex-column"> + <span class="font-weight-bold">{{ data.name }}</span> + <span class="text-caption text-grey">{{ data.role }}</span> + </div> + </template> +</ue-configurable-card> +``` diff --git a/docs/src/pages/guide/components/copy-text.md b/docs/src/pages/guide/components/copy-text.md new file mode 100644 index 000000000..5be56b8e3 --- /dev/null +++ b/docs/src/pages/guide/components/copy-text.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 10 +sidebarTitle: Copy Text +--- +# Copy Text + +The `ue-copy-text` component renders a clipboard icon that copies a value to the clipboard on click. A "Copied!" tooltip is shown for 2 seconds after a successful copy. + +## Usage + +```html +<ue-copy-text :text="item.api_key" /> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `text` | `String\|Number` | required | The value written to the clipboard | +| `icon` | `String` | `'mdi-content-copy'` | MDI icon name for the trigger | +| `color` | `String` | `'primary'` | Icon color | +| `size` | `String` | `'small'` | Icon size (`x-small`, `small`, `default`, `large`, `x-large`) | + +## Examples + +```html +<!-- Copy an API key --> +<div class="d-flex align-center ga-2"> + <span class="text-caption font-weight-mono">{{ item.api_key }}</span> + <ue-copy-text :text="item.api_key" /> +</div> + +<!-- Custom icon and color --> +<ue-copy-text :text="shareUrl" icon="mdi-share-variant" color="secondary" size="default" /> +``` + +::: tip Clipboard Access +Internally uses `this.$copy()`, which wraps the [Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API). Copy only works in secure contexts (HTTPS or localhost). +::: diff --git a/docs/src/pages/guide/components/currency-number.md b/docs/src/pages/guide/components/currency-number.md new file mode 100644 index 000000000..886f50f1c --- /dev/null +++ b/docs/src/pages/guide/components/currency-number.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 48 +sidebarTitle: Currency Number +--- +# Currency Number + +`ue-currency-number` is a numeric text field that formats its value as a currency string while editing. It wraps Vuetify's `v-text-field` and delegates formatting logic to the `useCurrencyNumber` composable. + +## Usage + +```html +<ue-currency-number + v-model="price" + label="Price" +/> +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `modelValue` | `Number` | The numeric value controlled via `v-model` | +| `errorMessages` | `Array` | Validation error messages forwarded to the underlying `v-text-field` | + +## Behaviour + +- The displayed value is formatted as a currency string (thousands separators, decimal places) while the bound `modelValue` remains a plain `Number`. +- Standard Vuetify `v-text-field` props such as `label`, `error`, `variant`, `density`, and `placeholder` are forwarded via `$bindAttributes`. +- All `v-text-field` slots are forwarded so you can use `#prepend-inner`, `#append-inner`, etc. + +::: tip +`ue-currency-number` is typically used as a form schema input type. Set `type: 'currency'` in your field schema to have `ue-form` render it automatically. +::: diff --git a/docs/src/pages/guide/components/data-iterators.md b/docs/src/pages/guide/components/data-iterators.md new file mode 100644 index 000000000..aa2c424d9 --- /dev/null +++ b/docs/src/pages/guide/components/data-iterators.md @@ -0,0 +1,68 @@ +--- +sidebarPos: 37 +sidebarTitle: Data Iterators +--- +# Data Iterators + +Modularous ships three data-iterator components that render a single row/card from a data set with support for column formatters and row actions. They are used by `ue-data-table` when the display mode is set to `row`, `card`, or `expandable`. + +All three share the same prop interface via `makeTableIteratorProps()`. + +## Common Props + +| Prop | Type | Description | +|------|------|-------------| +| `item` | `Object` | The data record for this row/card | +| `headers` | `Array` | Column header definitions (same shape as `ue-data-table` headers) | +| `rowActions` | `Array` | Action button definitions (icon, name, condition) | +| `iteratorOptions` | `Object` | Display configuration — which fields map to which visual areas (see per-component details below) | + +## `ue-rich-row-iterator` + +Renders a record as a horizontal card with a title, four named columns (firstColumn, secondColumn, featured, lastColumn), and action buttons. + +```js +iteratorOptions: { + headerKey: 'name', // field shown as card title + firstColumn: ['field1', 'field2'], + secondColumn: ['field3'], + featured: 'status', // visually prominent field (col 3) + lastColumn: 'created_at', +} +``` + +## `ue-rich-card-iterator` + +Renders a record as a vertical card with a cover image, a title row, and a compact table of all header fields. Used in grid/masonry layouts. + +```js +iteratorOptions: { + imgSrc: 'thumbnail_url', // header key whose value is the image URL +} +``` + +## `ue-expandable-iterator` + +Renders a record as a collapsible row. Clicking the row toggles a second field visible below the header. + +```js +iteratorOptions: { + headerKey: 'name', // field shown in the collapsed row + expandedKey: 'description', // field revealed on expand +} +``` + +## Row Actions + +All iterators emit row action events when action buttons are clicked. Actions are defined as: + +```js +rowActions: [ + { name: 'edit', icon: 'mdi-pencil', condition: (item) => item.can_edit }, + { name: 'delete', icon: 'mdi-trash-can' }, +] +``` + +::: tip +You typically do not use these components directly — set `display-mode` on `ue-data-table` to `row`, `card`, or `expandable` and the table switches iterator automatically. +::: diff --git a/docs/src/pages/guide/components/data-tables.md b/docs/src/pages/guide/components/data-tables.md index 20acb03b1..f8d3c12c5 100644 --- a/docs/src/pages/guide/components/data-tables.md +++ b/docs/src/pages/guide/components/data-tables.md @@ -1,12 +1,11 @@ --- -# https://vitepress.dev/reference/default-theme-home-page sidebarPos: 1 - +sidebarTitle: Data Tables --- # Data Tables -The data table component is used for displaying registered data in your index pages. Despite tabular user-interface is auto constructed while related route generation process, most listing functionalities are can be customized. +The data table component is used for displaying registered data in your index pages. Despite tabular user-interface is auto constructed while related route generation process, most listing functionalities can be customized. ::: info Customization @@ -16,17 +15,17 @@ Table functionalities and user-interface is highly customizable. In order to cus ::: tip See Also -For the table flow (useTable, store/api/datatable), see [Frontend — Table Flow](/system-reference/frontend#table-flow). +For the table flow (useTable, store/api/datatable), see [Frontend — Table Flow](/system-reference/frontend/overview#table-flow). ::: ## Table Component Defaults -In default, Modularity package automatically generates an default table user-interface with default table functionalities like `create new button`, `filtering`, `pagination` and `an embeded create-edit form` based on served functionalities of route itself and `user's permission`. Furthermore, based on registered data properties and user's permissions, item actions like `delete`, `restore` will be placed. +In default, Modularous package automatically generates a default table user-interface with default table functionalities like `create new button`, `filtering`, `pagination` and `an embeded create-edit form` based on served functionalities of route itself and `user's permission`. Furthermore, based on registered data properties and user's permissions, item actions like `delete`, `restore` will be placed. -It is avaliable to serve desired user-interface and user-experience on each data table via related module config files. Go your module's `config.php` and customize `table_options` key-value pairs to observe change. +It is available to serve desired user-interface and user-experience on each data table via related module config files. Go your module's `config.php` and customize `table_options` key-value pairs to observe change. ## Table and Related Component Props -Following table will show customizable key-value pairs, their description and default values. In order to observe better, you can visit blablabla +Following table will show customizable key-value pairs, their description and default values. In order to observe better, you can visit the [module config reference](/get-started/creating-modules) and change the values in your module's `config.php`. #### `embeddedForm` @@ -68,14 +67,14 @@ Visual serving option of the item actions like `delete`,`edit` inline or with a <br/> #### `tableClasses` -Applies extra css classes to data table. Also, modularity serves some default css classes that can be used. +Applies extra css classes to data table. Also, Modularous serves some default css classes that can be used. - `Input Type:` `String` - `Variance`: `No Variance` - `default`: `elevation-2` ::: tip Table Style Classes -Utility classes served under [VuetifyJS-Utility Classes](https://vuetifyjs.com/en/styles/borders/#sass-variables) can be observed and be used to construct customized data-table. Also modularity serves pre-defined styles which are `zebra-stripes`, `free-form`, `grid-form`. +Utility classes served under [VuetifyJS-Utility Classes](https://vuetifyjs.com/en/styles/borders/#sass-variables) can be observed and be used to construct customized data-table. Also Modularous serves pre-defined styles which are `zebra-stripes`, `free-form`, `grid-form`. ::: <br/> @@ -141,7 +140,7 @@ Shows the column with checkboxes for selecting items in the list. Bulk actions c #### `addBtnOptions` -[Vuetify's default Button Component](https://vuetifyjs.com/en/components/buttons/#usage) is used to construct create button user-interface and some functionality. It can be customize the props vuetify serves and some extra props modularity serves. +[Vuetify's default Button Component](https://vuetifyjs.com/en/components/buttons/#usage) is used to construct create button user-interface and some functionality. It can customize the props vuetify serves and some extra props Modularous serves. - `Input Type: Array` @@ -162,7 +161,7 @@ Shows the column with checkboxes for selecting items in the list. Bulk actions c <br/> #### `filterBtnOptions` -[Vuetify's default Button Component](https://vuetifyjs.com/en/components/buttons/#usage) is used to construct filter button user-interface and some functionality. It can be customize the props vuetify button serves and some extra props modularity serves. +[Vuetify's default Button Component](https://vuetifyjs.com/en/components/buttons/#usage) is used to construct filter button user-interface and some functionality. It can customize the props vuetify button serves and some extra props Modularous serves. - `Input Type: Array` @@ -181,7 +180,7 @@ Shows the column with checkboxes for selecting items in the list. Bulk actions c ``` ::: warning Button Props -All props served under [Vuetify.js Button API Page](https://vuetifyjs.com/en/api/v-btn/#links) are avaliable to use for filter and create button of tabular user-interface. +All props served under [Vuetify.js Button API Page](https://vuetifyjs.com/en/api/v-btn/#links) are available to use for filter and create button of tabular user-interface. ::: <br/> @@ -197,7 +196,7 @@ Controls the number of items to display on each page. #### `paginationOptions` -Pagination options controls pagination functionalities and pagination user-interface placed on the table footer. This version of modularity serves three different pagination options which are, `default`, `vuePagination`, and `infiniteScroll`. +Pagination options controls pagination functionalities and pagination user-interface placed on the table footer. This version of Modularous serves three different pagination options which are, `default`, `vuePagination`, and `infiniteScroll`. - `Input Type:` `Array` - `default: with default option` @@ -219,7 +218,7 @@ Pagination options controls pagination functionalities and pagination user-inter ``` ::: info Pagination Options - There are three different pagination options `default`, `vuePagination`, and `infiniteScroll` Modularity serves. + There are three different pagination options `default`, `vuePagination`, and `infiniteScroll` Modularous serves. ::: ::: tip Default Pagination diff --git a/docs/src/pages/guide/components/dropdown-filter.md b/docs/src/pages/guide/components/dropdown-filter.md new file mode 100644 index 000000000..e1863dd2d --- /dev/null +++ b/docs/src/pages/guide/components/dropdown-filter.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 30 +sidebarTitle: Dropdown Filter +--- +# Dropdown Filter + +`ue-dropdown-filter` presents a `v-menu` triggered by a button. The menu contains a `ue-form` built from a `schema` prop, giving users a compact way to apply structured filters without a full-page filter bar. + +## Usage + +```html +<ue-dropdown-filter + v-model:filterState="filterState" + :schema="filterSchema" + :page="currentPage" + type="users" + @submit="loadData" + @clear="resetFilters" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `filterState` | `Object` | required | Current filter values, kept in sync via `v-model:filterState` | +| `schema` | `Object\|Array` | required | `ue-form` schema describing the filter fields | +| `page` | `Number` | required | Current page number (forwarded for pagination resets) | +| `type` | `String` | required | Resource type identifier used by the parent to scope the filter | +| `buttonText` | `String` | `'Filter'` | Label on the activator button | +| `loading` | `Boolean` | `false` | Shows a loading indicator on the submit button | +| `tags` | `Array` | `[]` | Optional tag list for tag-based filtering UI | +| `filterModel` | `Object` | required | Initial model shape passed to the internal form | + +## Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:filterState` | `Object` | Emitted with the new filter values on submit | +| `submit` | — | Emitted after the menu closes following a filter apply | +| `clear` | — | Emitted when "Clear Filters" is clicked | + +## Behaviour + +- The menu closes automatically after "Apply Filters" or "Clear Filters" is clicked. +- The internal `ue-form` model is a local copy of `filterState`, so the parent state is only updated on explicit submit — not on every field change. diff --git a/docs/src/pages/guide/components/dynamic-component-renderer.md b/docs/src/pages/guide/components/dynamic-component-renderer.md new file mode 100644 index 000000000..abe942227 --- /dev/null +++ b/docs/src/pages/guide/components/dynamic-component-renderer.md @@ -0,0 +1,36 @@ +--- +sidebarPos: 25 +sidebarTitle: Dynamic Component Renderer +--- +# Dynamic Component Renderer + +`ue-dynamic-component-renderer` parses a component tag string and renders the described component dynamically. If the subject is not a recognisable component string, it renders it as raw HTML. + +## Usage + +```html +<!-- Renders a ue-chip programmatically --> +<ue-dynamic-component-renderer subject="<ue-chip color='success'>Active</ue-chip>" /> + +<!-- Renders raw HTML if not a component string --> +<ue-dynamic-component-renderer subject="<strong>Bold text</strong>" /> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `subject` | `String` | `''` | An HTML/component string to parse and render | + +## Behaviour + +- If `subject` starts with `<v-` or `<ue-` and ends with `>`, it is parsed as a Vue component: the tag name is extracted, attributes are bound via `v-bind`, and the inner text is set as the default slot content. +- Otherwise, the string is injected as raw HTML via `v-html`. + +::: warning +`ue-dynamic-component-renderer` uses `v-html` for non-component strings. Never pass unsanitised user input. +::: + +::: tip When to use +This component is used internally by `ue-recursive-stuff` and `ue-configurable-card` when a segment value is a primitive string. In most cases you should prefer `ue-recursive-stuff` with a full configuration object for more control. +::: diff --git a/docs/src/pages/guide/components/dynamic-modal.md b/docs/src/pages/guide/components/dynamic-modal.md new file mode 100644 index 000000000..83e956319 --- /dev/null +++ b/docs/src/pages/guide/components/dynamic-modal.md @@ -0,0 +1,69 @@ +--- +sidebarPos: 22 +sidebarTitle: Dynamic Modal +--- +# Dynamic Modal + +`ue-dynamic-modal` is a singleton dialog that is mounted once at the application root and driven entirely by the `modalService` injection. Use it to open any component inside a modal programmatically — without needing to place `ue-modal` in every view. + +## How It Works + +The `useDynamicModal()` composable returns a service object with `open()` and `close()` methods. Calling `open()` populates the singleton's internal state, which causes the modal to render the target component in its `body.description` slot. + +```js +import { useDynamicModal } from '@/hooks' + +const DynamicModal = useDynamicModal() + +DynamicModal.open('my-component-name', { + props: { /* props forwarded to the component */ }, + modalProps: { title: 'My Title', widthType: 'md' }, + emits: { confirm: () => { /* handle confirm */ } }, +}) + +DynamicModal.close() +``` + +## Service API + +| Method | Signature | Description | +|--------|-----------|-------------| +| `open` | `(component, options)` | Render `component` inside the modal | +| `close` | `()` | Close the modal | + +### `options` object + +| Key | Type | Description | +|-----|------|-------------| +| `props` | `Object` | Props forwarded to the dynamic component | +| `modalProps` | `Object` | Props forwarded to the underlying `ue-modal` | +| `emits` | `Object` | Event handlers keyed by event name | +| `slots` | `Object` | Configuration-driven slot content for `ue-recursive-stuff` | +| `data` | any | Arbitrary data available inside the rendered component via `inject('modalRef').data` | + +## Inside the Rendered Component + +The component rendered inside `ue-dynamic-modal` can access a `modalRef` injection: + +```js +import { inject } from 'vue' + +const modalRef = inject('modalRef') +modalRef.close() // close the modal +modalRef.data // the data passed in options.data +``` + +## Example — Open a Confirmation Component + +```js +const DynamicModal = useDynamicModal() + +DynamicModal.open('ue-error-card', { + props: { statusCode: 403, statusText: 'Not Allowed' }, + modalProps: { title: 'Access Denied', widthType: 'sm', noActions: true }, +}) +``` + +::: tip Mounting +`ue-dynamic-modal` is already mounted inside `ue-auth` and the main layout (`ue-main`). You do not need to add it to your views manually. +::: diff --git a/docs/src/pages/guide/components/error-card.md b/docs/src/pages/guide/components/error-card.md new file mode 100644 index 000000000..050f3d82e --- /dev/null +++ b/docs/src/pages/guide/components/error-card.md @@ -0,0 +1,59 @@ +--- +sidebarPos: 16 +sidebarTitle: Error Card +--- +# Error Card + +`ue-error-card` renders a full-featured HTTP error page inside a `v-card`. It displays a status code, title, description, an alert, and navigation buttons (Go Back / Home). All text is passed through `$t()` for i18n support. + +## Usage + +```html +<!-- 403 Forbidden (defaults) --> +<ue-error-card /> + +<!-- 404 Not Found --> +<ue-error-card + :status-code="404" + status-text="Page Not Found" + description="The page you are looking for does not exist." + alert="info" + icon="mdi-alert-circle-outline" + home-url="/dashboard" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `statusCode` | `Number` | `403` | HTTP status code displayed as a large heading | +| `statusText` | `String` | `'Access Forbidden'` | Title below the status code | +| `description` | `String` | `'You don\'t have permission to access this resource.'` | Body copy | +| `alertText` | `String` | `'This action is restricted for modularous authenticated users.'` | Text inside the alert box | +| `alert` | `String` | `'warning'` | Alert type: `'warning'`, `'error'`, `'info'`, `'success'` | +| `icon` | `String` | `'mdi-lock-outline'` | MDI icon shown at the top of the card | +| `iconSize` | `Number` | `120` | Icon size in pixels | +| `elevation` | `Number` | `8` | Card shadow elevation | +| `rounded` | `String` | `'lg'` | Card border radius | +| `homeUrl` | `String` | `'/'` | URL for the "Home" button | +| `homeText` | `String` | `'Home'` | Label for the "Home" button | + +## Examples + +```html +<!-- 500 Server Error --> +<ue-error-card + :status-code="500" + status-text="Server Error" + description="Something went wrong on our end." + alert="error" + icon="mdi-server-off" + home-url="/dashboard" + home-text="Back to Dashboard" +/> +``` + +::: tip Usage in Blade Views +In Modularous, permission and access errors are typically caught in middleware and redirected to a dedicated error Blade view. Place `<ue-error-card />` inside the page's Vue mount to take advantage of the built-in navigation buttons. +::: diff --git a/docs/src/pages/guide/components/expansion.md b/docs/src/pages/guide/components/expansion.md new file mode 100644 index 000000000..6f2c7ef7d --- /dev/null +++ b/docs/src/pages/guide/components/expansion.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 13 +sidebarTitle: Expansion +--- +# Expansion + +The `ue-expansion` component is a single-panel wrapper around Vuetify's `v-expansion-panels`. Use it when you need one collapsible section with an optional action icon in the header. + +## Usage + +```html +<ue-expansion title="Details" :model-value="true"> + <p>Content goes here.</p> +</ue-expansion> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Boolean` | `true` | Whether the panel starts expanded | +| `title` | `String` | `''` | Panel header text | +| `multiple` | `Boolean` | `false` | Allow multiple panels open simultaneously (forwarded to `v-expansion-panels`) | +| `readonly` | `Boolean` | `false` | Prevent the panel from being toggled | +| `hasActions` | `Boolean` | `false` | Show a custom chevron icon in the header actions slot | +| `expandedIcon` | `String` | `'mdi-chevron-down'` | Icon shown when the panel is expanded | +| `collapsedIcon` | `String` | `'mdi-chevron-up'` | Icon shown when the panel is collapsed | + +## Slots + +| Slot | Description | +|------|-------------| +| `default` | Body content rendered inside the expansion panel | + +## Examples + +```html +<!-- Collapsed by default --> +<ue-expansion title="Advanced Settings" :model-value="false"> + <!-- settings inputs --> +</ue-expansion> + +<!-- Read-only (always expanded, not togglable) --> +<ue-expansion title="System Info" readonly> + <ue-property-list :data="systemInfo" /> +</ue-expansion> + +<!-- Custom action icons --> +<ue-expansion title="Notifications" has-actions expanded-icon="mdi-bell" collapsed-icon="mdi-bell-off"> + <!-- notification settings --> +</ue-expansion> +``` + +::: tip vs Collapsible +`ue-expansion` is backed by Vuetify's `v-expansion-panels` and follows its styling system. `ue-collapsible` is a lighter custom component with more layout control (borders, padding, dense mode). For simple single-section toggles in forms or cards, either works; prefer `ue-collapsible` when you need more styling control. +::: diff --git a/docs/src/pages/guide/components/file-item.md b/docs/src/pages/guide/components/file-item.md new file mode 100644 index 000000000..2567a0315 --- /dev/null +++ b/docs/src/pages/guide/components/file-item.md @@ -0,0 +1,40 @@ +--- +sidebarPos: 49 +sidebarTitle: File Item +--- +# File Item + +`ue-file-item` renders a single row in a file attachment table. It shows the file extension icon, name, size, and a remove button. It is used internally by file-upload form fields. + +## Usage + +```html +<table> + <tbody> + <ue-file-item + v-for="file in files" + :key="file.id" + name="attachments[]" + :item="file" + :draggable="true" + /> + </tbody> +</table> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `name` | `String` | required | The `name` attribute for the hidden `<input type="hidden">` that submits the file ID | +| `item` | `Object` | `{}` | File object. Supports `id`, `name`, `size`, `extension`, `thumbnail`, `original` keys | +| `draggable` | `Boolean` | `false` | Show a drag handle cell for reordering | +| `itemLabel` | `String` | `'Item'` | Label used in confirmation messages | +| `endpoint` | `String` | `''` | Delete endpoint — called when the remove button is clicked | +| `max` | `Number` | `10` | Maximum number of files allowed (informational) | + +## Behaviour + +- If `item.extension` is present, an SVG icon matching the extension is shown. +- If `item.thumbnail` is present, a thumbnail `<img>` is shown alongside the file name. +- Clicking the close button removes the row from the file list. diff --git a/docs/src/pages/guide/components/filepond-preview.md b/docs/src/pages/guide/components/filepond-preview.md new file mode 100644 index 000000000..94b04ce41 --- /dev/null +++ b/docs/src/pages/guide/components/filepond-preview.md @@ -0,0 +1,36 @@ +--- +sidebarPos: 41 +sidebarTitle: FilePond Preview +--- +# FilePond Preview + +`ue-filepond-preview` renders a list of uploaded file entries as thumbnail cards or icon cards. Image files show a thumbnail fetched from `/api/filepond/preview/:uuid`; non-image files show a type-appropriate MDI icon. Hovering reveals download and preview actions. + +## Usage + +```html +<ue-filepond-preview + :source="attachments" + :image-size="64" + show-file-name +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `source` | `Object\|Array` | required | Single file object or array of file objects. Each entry must have `uuid`, `file_name`, and `created_at` fields | +| `imageSize` | `Number\|String` | `64` | Width and height in pixels for each file card/thumbnail | +| `showFileName` | `Boolean` | `false` | Show the file name below the thumbnail | +| `showInlineFileName` | `Boolean` | `false` | Show the file name inline to the right of the thumbnail (takes full row width) | +| `maxFileNameLength` | `Number` | `10` | Maximum characters shown in the file name before truncation | +| `noOverlay` | `Boolean` | `false` | Disable the hover overlay with download/preview buttons | +| `showDate` | `Boolean` | `false` | Show the `created_at` date on each card | + +## Behaviour + +- **Images** (jpg, jpeg, png, gif, webp): fetches a preview blob from `/api/filepond/preview/:uuid` and displays it as a `v-img`. +- **Non-images**: displays an appropriate MDI icon (PDF, Word, Excel, or generic document). +- Clicking a previewable file opens a fullscreen dialog with the file in an `<iframe>` or `<v-img>`. +- Clicking a non-previewable file triggers a download via `/api/filepond/download/:uuid`. diff --git a/docs/src/pages/guide/components/filter.md b/docs/src/pages/guide/components/filter.md new file mode 100644 index 000000000..f7fe42ee3 --- /dev/null +++ b/docs/src/pages/guide/components/filter.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 29 +sidebarTitle: Filter +--- +# Filter + +`ue-filter` provides a search input with an optional collapsible hidden-filters panel. It is designed to sit above a data table or list and drives an external `filterState` object via `v-model`. + +## Usage + +```html +<ue-filter + v-model:filterState="filterState" + @submit="applyFilters" + @clear="clearFilters" +> + <!-- optional: extra controls next to the search input --> + <template #additional-actions> + <v-btn @click="exportCsv">Export</v-btn> + </template> + + <!-- optional: additional filter fields revealed on toggle --> + <template #hidden-filters> + <ue-input-select name="status" label="Status" :items="statusOptions" /> + </template> +</ue-filter> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `filterState` | `Object` | required | Current filter values. Must contain at least a `search` key | +| `initialSearchValue` | `String` | `''` | Pre-populated search value on mount | +| `placeholder` | `String` | i18n `filter.search-placeholder` | Search input placeholder text | +| `closed` | `Boolean` | `false` | Keep the hidden-filters panel closed even after toggle | +| `clearOption` | `Boolean` | `false` | Show a "Clear" button inside the hidden-filters panel | + +## Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:filterState` | `Object` | Emitted when the search value changes | +| `submit` | `Object` | Emitted when the form is submitted; payload is the serialised form data | +| `clear` | — | Emitted when the "Clear" button is clicked | + +## Slots + +| Slot | Description | +|------|-------------| +| `navigation` | Content placed to the left of the search field (e.g. tabs or segment controls) | +| `additional-actions` | Content placed to the right of the search field | +| `hidden-filters` | Filter fields revealed by the toggle button; hidden when this slot is empty | +| `default` | Content rendered below the filter bar | + +## Behaviour + +- The hidden-filters panel uses a CSS height-transition for smooth expand/collapse. +- If the `#hidden-filters` slot is not provided, the toggle button is not rendered. +- If the `#navigation` slot is not provided, the navigation area is hidden. diff --git a/docs/src/pages/guide/components/form-actions.md b/docs/src/pages/guide/components/form-actions.md new file mode 100644 index 000000000..bd4a70cb7 --- /dev/null +++ b/docs/src/pages/guide/components/form-actions.md @@ -0,0 +1,81 @@ +--- +sidebarPos: 44 +sidebarTitle: Form Actions & Events +--- +# Form Actions & Events + +`ue-form-actions` and `ue-form-events` are the two sub-components that render the action and event button bars inside `ue-form`. They are also used standalone in data table toolbars and detail pages. + +## `ue-form-actions` + +Renders a horizontal group of action buttons from an `actions` configuration object. Supports plain buttons, publish switches, inline form modals, and badge overlays. + +### Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `modelValue` | `Object` | yes | The current data record (used to build modal form models and pass to action handlers) | +| `actions` | `Object` | yes | Map of action definitions keyed by name (see Action Shape below) | +| `isEditing` | `Boolean` | — | Whether the context is an edit form — forwarded to inline form modals | + +### Action Shape + +```js +{ + type: 'button', // 'button' | 'modal' | 'publish' + label: 'Export', // button label + icon: 'mdi-export', // MDI icon (makes it icon-only unless forceLabel: true) + color: 'primary', + endpoint: '/api/...', // required for type: 'modal' + schema: [...], // ue-form schema for type: 'modal' inline form + formTitle: 'Edit Notes', // title for the inline form modal + modalAttributes: {}, // props forwarded to ue-modal for type: 'modal' + formAttributes: {}, // props forwarded to ue-form for type: 'modal' + tooltip: 'Export CSV', // tooltip text (defaults to label) + disabled: false, + badge: { content: 3, color: 'error' }, // optional v-badge config +} +``` + +### Events + +| Event | Description | +|-------|-------------| +| `actionComplete` | Emitted after an inline form modal submits successfully | + +### Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `prepend` | `{ item, isEditing }` | Content placed before the action buttons | +| `append` | `{ item, isEditing }` | Content placed after the action buttons | + +--- + +## `ue-form-events` + +Renders a row of event buttons (segmented controls, dropdowns, or toggle groups) defined by an `events` array. Used in `ue-form` to present contextual state-changing options alongside the main form. + +### Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `events` | `Array` | yes | Array of event definitions (see Event Shape below) | +| `modelValue` | `Object` | yes | Current form model — event selections are read from and written to this | +| `formItem` | `Object` | — | The raw form field descriptor | + +### Event Shape + +```js +{ + name: 'status', // key written to modelValue + type: 'chip-group', // 'chip-group' | 'select' | etc. + label: 'Status', + items: [ + { value: 'active', label: 'Active' }, + { value: 'draft', label: 'Draft' }, + ], + itemValue: 'value', + itemTitle: 'label', +} +``` diff --git a/docs/src/pages/guide/components/form-summary-item.md b/docs/src/pages/guide/components/form-summary-item.md new file mode 100644 index 000000000..5af0d3c15 --- /dev/null +++ b/docs/src/pages/guide/components/form-summary-item.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 45 +sidebarTitle: Form Summary Item +--- +# Form Summary Item + +`ue-form-summary-item` renders a single step's summary in the stepper form review panel. It displays a numbered step label, the step title, and a formatted view of the step's model values. + +## Usage + +This component is rendered automatically by `ue-stepper-form` in the final "Preview & Summary" step. You can also override the default slot for a custom summary: + +```html +<ue-stepper-form ...> + <template #summary-form-1="{ index, title, model }"> + <ue-form-summary-item :index="index" :title="title" :model="model" /> + </template> +</ue-stepper-form> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `index` | `Number` | yes | Zero-based step index. Displayed as `index + 1` | +| `title` | `String` | yes | Step title shown below the step number | +| `model` | `Object` | yes | Step form model values to display. Each key-value pair is rendered as a chip or labelled text | + +## Behaviour + +- Each key in `model` is iterated. If the value is an array, the first element is used as a label and subsequent elements are displayed inline. +- Non-array primitive values are rendered as read-only outlined `v-btn` chips. +- The step number badge uses a 25%-border-radius avatar styled in the primary colour. diff --git a/docs/src/pages/guide/components/form.md b/docs/src/pages/guide/components/form.md new file mode 100644 index 000000000..6b50ee84e --- /dev/null +++ b/docs/src/pages/guide/components/form.md @@ -0,0 +1,111 @@ +--- +sidebarPos: 5 +sidebarTitle: Form +--- +# Form + +The `ue-form` component is the primary schema-driven form wrapper in Modularous. It wraps Vuetify's `v-form`, renders inputs from a schema object, handles submission, validation, and optional right-side content panels. + +## Basic Usage + +```php +@php + $schema = $this->createFormSchema([ + ['type' => 'text', 'name' => 'name', 'label' => 'Name'], + ['type' => 'email', 'name' => 'email', 'label' => 'Email'], + ]); +@endphp + +<ue-form + :model-value='@json($item)' + :schema='@json($schema)' + action-url="{{ route('module.store') }}" + title="Create User" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Object` | required | The form data model | +| `schema` | `Object` | required | Input schema — built with `createFormSchema()` | +| `actionUrl` | `String` | — | Form submission endpoint | +| `title` | `String\|Object` | — | Header title. Pass an object for advanced options (type, weight, color, etc.) | +| `subtitle` | `String` | — | Subtitle shown below the title | +| `noTitle` | `Boolean` | `false` | Hide the title row | +| `isEditing` | `Boolean` | `false` | Switches form into edit mode (PUT vs POST) | +| `async` | `Boolean` | `true` | Submit via axios; if `false`, submits the native HTML form | +| `hasSubmit` | `Boolean` | `false` | Render a built-in submit button | +| `buttonText` | `String` | — | Label for the built-in submit button | +| `hasDivider` | `Boolean` | `false` | Show a divider below the title row | +| `fillHeight` | `Boolean` | `false` | Stretch the form to fill available viewport height | +| `scrollable` | `Boolean` | `false` | Make the input area scrollable (useful inside modals) | +| `formClass` | `String\|Array` | `''` | Extra CSS classes on the inner `v-form` | +| `noDefaultFormPadding` | `Boolean` | `false` | Remove the default `pa-4` padding | +| `noDefaultSurface` | `Boolean` | `false` | Remove the default `bg-surface` background | +| `actions` | `Array\|Object` | `[]` | Action button definitions rendered by `FormActions` | +| `actionsPosition` | `String` | `'top'` | Where actions render: `title-right`, `title-center`, `top`, `middle`, `bottom`, `right-top`, `right-middle`, `right-bottom` | +| `rowAttribute` | `Object` | `{noGutters: false, class: 'py-4'}` | Props forwarded to the wrapping `v-row` around inputs | +| `rightSlotWidth` | `Number\|String` | `null` | Fixed width (px) of the right-side panel | +| `rightSlotMinWidth` | `Number\|String` | `300` | Min width (px) of the right-side panel | +| `rightSlotMaxWidth` | `Number\|String` | `600` | Max width (px) of the right-side panel | +| `rightSlotGap` | `Number` | `12` | Margin between the form and the right panel | +| `clearOnSaved` | `Boolean` | `false` | Reset form after a successful save | +| `refreshOnSaved` | `Boolean` | `false` | Reload the datatable after a successful save | +| `noWaitSourceLoading` | `Boolean` | `false` | Render inputs immediately without waiting for async source data | + +## Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `header.left` | `{title, subtitle, model, schema, formItem}` | Replaces the left side of the title row | +| `headerCenter` | — | Extra content injected into the title row center | +| `top` | `{item, schema}` | Content above the input block | +| `underside` | `{isEditing, item, schema}` | Content below the input block | +| `actions.prepend` | actions scope | Prepend content before action buttons | +| `actions.append` | actions scope | Append content after action buttons | +| `right-top` | — | Content at the top of the right panel | +| `right-middle` | — | Content in the middle of the right panel | +| `right-bottom` | — | Content at the bottom of the right panel | + +## Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `Object` | Emitted on every input change | +| `update:valid` | `Boolean` | Emitted when form validation state changes | +| `input` | input event object | Raw input event from the form base | +| `submitted` | response data | Emitted after a successful async submission | +| `actionComplete` | action result | Emitted when a `FormActions` button completes | + +## Example — Form Inside a Blade View + +```php +@php + $schema = $this->createFormSchema([ + ['type' => 'text', 'name' => 'title', 'label' => 'Title', 'rules' => 'required'], + ['type' => 'textarea','name' => 'body', 'label' => 'Body'], + ['type' => 'select', 'name' => 'status', 'label' => 'Status', + 'items' => [ + ['value' => 'draft', 'label' => 'Draft'], + ['value' => 'published', 'label' => 'Published'], + ] + ], + ]); +@endphp + +<ue-form + :model-value='@json($post ?? [])' + :schema='@json($schema)' + action-url="{{ route('posts.store') }}" + title="New Post" + has-submit + button-text="Save" + is-editing="{{ isset($post) ? 'true' : 'false' }}" +/> +``` + +::: tip Schema Builder +The `createFormSchema()` helper (available inside Modularous controllers and views) normalises raw field arrays into the schema format `ue-form` expects. See [Hydrates](/system-reference/hydrates) for the full list of supported input types. +::: diff --git a/docs/src/pages/guide/components/forms.md b/docs/src/pages/guide/components/forms.md deleted file mode 100644 index 616d352d6..000000000 --- a/docs/src/pages/guide/components/forms.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -sidebarPos: 2 ---- - -# Forms - -Modularity forms are schema-driven. The backend hydrates module config into a schema; the frontend renders it via FormBase and FormBaseField. - -## Flow - -1. **Module config** — Define inputs in your module's `config.php` (see [Hydrates](/system-reference/hydrates)) -2. **Controller** — `setupFormSchema()` hydrates the schema before create/edit -3. **Inertia** — Schema and model are passed to the page -4. **Form.vue** — Receives `schema` and `modelValue`, uses `useForm` -5. **FormBase** — Flattens schema + model into `flatCombinedArraySorted`, iterates over each field -6. **FormBaseField** — Renders each field by `obj.schema.type` via `mapTypeToComponent()` -7. **Input components** — Receive schema props via `bindSchema(obj)` - -## Key Components - -| Component | Purpose | -|-----------|---------| -| **Form.vue** | Top-level form; validation, submit, schema/model sync | -| **FormBase** | Iterates over flattened schema; grid layout, slots | -| **FormBaseField** | Renders a single field; resolves type → component | -| **CustomFormBase** | Wrapper with app-specific behavior | - -## Schema Structure - -Each field in the schema has: - -- `type` — Resolved to Vue component (e.g. `input-checklist`, `text`, `select`) -- `name` — Field name (binds to model) -- `label` — Display label -- `col` — Grid column span -- `rules` — Validation rules -- `default` — Default value - -See [Schema Contract](/system-reference/hydrates#schema-contract) for full keys. For config → schema flow per feature, see [Module Features Overview](/guide/module-features/). - -## Slots - -FormBase provides slots for customization: - -- `form-top`, `form-bottom` — Form-level -- `{type}-top`, `{type}-bottom` — By schema type (e.g. `input-checklist-top`) -- `{key}-top`, `{key}-bottom` — By field name -- `{type}-item`, `{key}-item` — Override field rendering - -## Adding Custom Inputs - -1. Create Vue component in `vue/src/js/components/inputs/` -2. Register: `registerInputType('input-my-type', 'VInputMyType')` -3. Create PHP Hydrate in `src/Hydrates/Inputs/` (for backend schema) - -See [Adding a New Input](/system-reference/api#adding-a-new-input-type). diff --git a/docs/src/pages/guide/components/impersonate-toolbar.md b/docs/src/pages/guide/components/impersonate-toolbar.md new file mode 100644 index 000000000..60a351a71 --- /dev/null +++ b/docs/src/pages/guide/components/impersonate-toolbar.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 31 +sidebarTitle: Impersonate Toolbar +--- +# Impersonate Toolbar + +`ue-impersonate-toolbar` lets administrators impersonate another user from the sidebar. It renders either a user-search input (when not impersonating) or a "Stop Impersonating" button (when actively impersonating). + +## Usage + +```html +<ue-impersonate-toolbar + v-model="showToolbar" + :impersonated="isImpersonating" + :fetch-endpoint="/api/users/search" + route="/users/impersonate/:id" + stop-route="/users/impersonate/stop" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Boolean` | `false` | Controls toolbar visibility via `v-model` | +| `active` | `Boolean` | `false` | Whether the impersonation feature is enabled at all | +| `impersonated` | `Boolean` | `false` | `true` when the current session is already impersonating a user | +| `users` | `Array` | `[]` | Static list of users — used when `fetchEndpoint` is not provided | +| `fetchEndpoint` | `String` | `null` | API endpoint for live user search (powers `v-input-browser`) | +| `route` | `String` | `'/users/impersonate/:id'` | Impersonate URL template; `:id` is replaced with the selected user id | +| `stopRoute` | `String` | `'/users/impersonate/stop'` | URL to navigate to when stopping impersonation | +| `itemTitle` | `String` | `'name'` | Field used as the display label in the user dropdown | +| `itemValue` | `String` | `'id'` | Field used as the option value | +| `density` | `String` | `'comfortable'` | Vuetify density for the input | +| `variant` | `String` | `'outlined'` | Vuetify variant for the input | + +## Behaviour + +- When `impersonated` is `true`, a red "Stop Impersonating" list item is shown. Clicking it navigates to `stopRoute`. +- When `impersonated` is `false`, a user search input is shown. Selecting a user immediately redirects to the `route` URL with the selected id substituted. +- This component is rendered automatically inside `ue-main` when the `impersonation.active` option is set in the main layout configuration. diff --git a/docs/src/pages/guide/components/input-radio-group.md b/docs/src/pages/guide/components/input-radio-group.md deleted file mode 100644 index 124f8a9d9..000000000 --- a/docs/src/pages/guide/components/input-radio-group.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -# sidebarPos: 3 ---- -# Radio Group <Badge type="tip" text="^0.9.2" /> - -The `v-input-radio-group` component presents radio button selectable wrapper. - -## Usage -``` php - [ - ..., - 'type' => 'radio-group', // type name - 'name' => '_radio-group', - 'itemValue' => 'id', - 'itemTitle' => 'name', - 'items' => [ - [ - 'id' => 1, - 'name' => 'Title 1', - ], - [ - 'id' => 2, - 'name' => 'Title 2', - ] - ], - ], -``` - -> [!IMPORTANT] -> This component was introduced in [v0.9.2] diff --git a/docs/src/pages/guide/components/input-select-scroll.md b/docs/src/pages/guide/components/input-select-scroll.md deleted file mode 100644 index 648d4bb2b..000000000 --- a/docs/src/pages/guide/components/input-select-scroll.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -# sidebarPos: 3 ---- -# Select Scrolls <Badge type="tip" text="^0.9.1" /> - -The `v-input-select-scroll` component offers simple async functionality. This is useful when loading large sets of data and while scrolling on menu of select. - -Default input type is **v-autocomplete**. - -## Usage -You can consider as standard select input, add input attributes to config as following: -``` php - [ - 'type' => 'autocomplete', // or 'select', 'combobox' - 'ext' => 'scroll', - 'connector' => '{ModuleName}:{RouteName}|uri', - ... - ], -``` -or -``` php - [ - 'type' => 'select-scroll', - 'connector' => '{ModuleName}:{RouteName}|uri', - ... - ], -``` - -> [!IMPORTANT] -> This component was introduced in [v0.9.1] - -## See also - -- [Module Features Overview](/guide/module-features/) — Features that use select output (Authorizable, Stateable) diff --git a/docs/src/pages/guide/components/labs/active-table-item.md b/docs/src/pages/guide/components/labs/active-table-item.md new file mode 100644 index 000000000..d460d24c0 --- /dev/null +++ b/docs/src/pages/guide/components/labs/active-table-item.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 10 +sidebarTitle: Active Table Item +--- + +# ActiveTableItem <Badge type="warning" text="experimental" /> + +`ActiveTableItem` provides a two-phase drill-down UI for a selected table row: + +1. **Selection modal** — a `ue-modal` dialog showing a grid of labelled blocks. The user clicks a block to select a sub-section. +2. **Detail panel** — once a block is selected, the modal closes and an inline `ue-table` renders the item's details alongside any additional elements configured for that block. + +The component delegates its logic entirely to `useActiveTableItem` and `makeActiveTableItemProps` from the internal `__hooks` module. + +> [!NOTE] +> This component is designed for use inside table row-click handlers. It is not a general-purpose component. + +## Behaviour + +``` +User clicks a row + → ActiveTableItem mounts with item data + → Modal opens showing block options + → User clicks a block (selectNested) + → modalActive = false, activeBlock = selected block + → ue-table renders item details for that block + → ue-recursive-stuff renders block.elements below the table + → User clicks ✕ to close (closeItemDetails) + → activeBlock = null +``` + +## Props + +Props are defined by `makeActiveTableItemProps()` from the `__hooks` module. The exact shape depends on the hook implementation, but typically includes: + +| Prop | Description | +|---|---| +| `item` | The full row data object for the selected record | +| `itemData` | Array of block definitions. Each block controls what appears in the modal and what is rendered in the detail panel. | +| `tableHeaders` | Column definitions forwarded to the detail `ue-table` | + +## Block definition + +Each entry in `itemData` configures one selectable block in the modal: + +| Key | Type | Description | +|---|---|---| +| `title` | `String` | Label shown on the block button | +| `elements` | `Array` | `ue-recursive-stuff` configuration rendered below the detail table | +| `clickBlock` | `Object` | Optional. If present, overrides the block button with a custom layout. `clickBlock.col` sets the `v-col` bindings; `clickBlock.elements` is passed to `ue-recursive-stuff` instead of a plain button. | + +## Detail table + +The inline `ue-table` is rendered with: +- `is-row-editing: false` +- `create-on-modal: false` / `edit-on-modal: false` +- `row-actions: []` +- `items-per-page: 1` (single-item view) +- Footer, form, and full-screen chrome are hidden +- A close (`✕`) button in `#headerRight` calls `closeItemDetails()` + +## Notes + +- `ignore-formatters: ['activate']` is passed to prevent the activate formatter from rendering on the detail table. +- The detail panel only shows when `item` is set, `modalActive` is `false`, and `activeBlock` is non-null. +- Customise the block layout using `clickBlock.elements` and `ue-recursive-stuff` configuration objects. diff --git a/docs/src/pages/guide/components/labs/callout.md b/docs/src/pages/guide/components/labs/callout.md new file mode 100644 index 000000000..e799b2a97 --- /dev/null +++ b/docs/src/pages/guide/components/labs/callout.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 8 +sidebarTitle: Callout +--- + +# Callout <Badge type="warning" text="experimental" /> + +`Callout` is a bordered alert card that presents a **title** on the left and a larger **value** on the right. It is built on top of `v-alert` with a 4 px start border and no outer border, giving it a clean callout / stat-card appearance. + +## Usage + +```html +<ue-callout + title="Total Revenue" + value="$12,400" + color="success" + bg-color="white" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `title` | `String` | `''` | Left-column label text | +| `value` | `String` | `''` | Right-column value text (rendered at 1.5 rem) | +| `color` | `String` | `'success'` | Border colour and default text colour (any Vuetify colour token) | +| `bgColor` | `String` | `'white'` | Background colour of the alert card | +| `textColor` | `String` | `''` | Explicit text colour override. Falls back to `color` when empty. | + +## Layout + +The component uses `RowFormat` internally to render a two-column row: + +| Column | Width | Content | +|---|---|---| +| Left | 8 / 12 | `title` — vertically centred, rendered at body size | +| Right | 4 / 12 | `value` — rendered at 1.5 rem font size | + +Both columns inherit the resolved text colour (either `textColor` or `color`). + +## Colour logic + +``` +textColor prop set? → use textColor +textColor prop empty? → use color +``` + +## CSS class + +The component adds `.v-callout` to the alert element. The accompanying scoped style sets: +- `border: unset` (removes the default Vuetify outer border) +- `--v-border-opacity: 1` (forces full opacity on the start border) +- `border-inline-start-width: 4px` + +## Example — multiple callouts + +```html +<ue-callout title="Subscribers" value="8,240" color="primary" /> +<ue-callout title="Impressions" value="142,000" color="info" /> +<ue-callout title="Conversions" value="3.2%" color="warning" /> +``` diff --git a/docs/src/pages/guide/components/labs/input-color.md b/docs/src/pages/guide/components/labs/input-color.md new file mode 100644 index 000000000..512906912 --- /dev/null +++ b/docs/src/pages/guide/components/labs/input-color.md @@ -0,0 +1,37 @@ +--- +sidebarPos: 4 +sidebarTitle: Input Color +--- + +# InputColor <Badge type="warning" text="experimental" /> + +`InputColor` renders a read-only text field that displays the stored hex value. An inlined colour swatch acts as a `v-menu` activator — clicking it opens a `v-color-picker` popover. + +## Schema usage + +```php +[ + 'type' => 'color', + 'name' => 'brand_color', + 'label' => 'Brand Color', +] +``` + +## Value format + +The stored value is a hex colour string including the `#` prefix, e.g. `#1A73E8`. The input mask `!XNNNNNNNN` enforces this format in the text field (case-insensitive, up to 9 characters). + +## Swatch behaviour + +| State | Swatch appearance | +|---|---| +| Picker closed | Square with 4px border-radius | +| Picker open | Circle (50% border-radius) | + +The transition between shapes is animated over 200 ms (`ease-in-out`). + +## Notes + +- The text field is `readonly`. The user can only select a colour via the picker; typing is blocked. +- Schema keys are forwarded to both the `v-text-field` and the `v-color-picker` via `obj.schema` — set `hide-inputs: true` on the schema if you want to suppress the hex/RGB input rows in the picker. +- No alpha channel support is configured by default. Pass `mode: 'hexa'` on the schema to enable it. diff --git a/docs/src/pages/guide/components/labs/input-date.md b/docs/src/pages/guide/components/labs/input-date.md new file mode 100644 index 000000000..ad8d1e168 --- /dev/null +++ b/docs/src/pages/guide/components/labs/input-date.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 1 +sidebarTitle: Input Date +--- + +# InputDate <Badge type="warning" text="experimental" /> + +`InputDate` wraps a native `<input type="date">` field inside the `ue-form` schema system. It normalises the value to an ISO date string (`YYYY-MM-DD`) and strips schema keys that conflict with the native date input (`offset`, `order`, `type`). + +## Schema usage + +```php +[ + 'type' => 'date', + 'name' => 'published_at', + 'label' => 'Publication Date', +] +``` + +## Value format + +| Direction | Format | +|---|---| +| Model → field | ISO string converted to `YYYY-MM-DD` via `Date.toISOString().split('T')[0]` | +| Field → model | Raw `YYYY-MM-DD` string as returned by the native date input | + +An empty model value renders an empty field (no default date is assumed). + +## Locale display + +The computed `dateFormattedLocale` property formats the stored value using `$d(date, 'medium')` from Vue I18n, but this is not currently rendered in the template — it is available for use in custom slot overrides or parent components. + +## Notes + +- The `type`, `offset`, and `order` keys are omitted from the props forwarded to `v-text-field` to prevent conflicts with the native `type="date"` attribute. +- No date-range constraints (`min` / `max`) are configured by default; pass them through the schema object. + +```php +[ + 'type' => 'date', + 'name' => 'start_date', + 'label' => 'Start Date', + 'min' => '2024-01-01', + 'max' => now()->toDateString(), +] +``` diff --git a/docs/src/pages/guide/components/labs/input-icon.md b/docs/src/pages/guide/components/labs/input-icon.md new file mode 100644 index 000000000..c7117e347 --- /dev/null +++ b/docs/src/pages/guide/components/labs/input-icon.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 6 +sidebarTitle: Input Icon +--- + +# InputIcon <Badge type="warning" text="experimental" /> + +`InputIcon` presents a read-only text field that opens a full-screen dialog containing a searchable grid of MDI icons. Clicking an icon saves its `mdi-{name}` string to the model. + +## Schema usage + +```php +[ + 'type' => 'icon', + 'name' => 'menu_icon', + 'label' => 'Menu Icon', +] +``` + +## Value format + +The stored value is the full MDI icon string with the `mdi-` prefix, e.g. `"mdi-account"`. + +## Behaviour + +1. The text field displays the current icon string (read-only). +2. Clicking the field opens a `v-dialog` (max width 700 px, max height 850 px). +3. A search field at the top filters the icon list in real time by substring match. +4. Clicking any icon button stores `mdi-{name}` in the model and closes the dialog. + +## Bundled icon set + +The component ships with a curated list of ~400 MDI icons covering common categories: account/user, alerts, arrows, calendars, files, formatting, navigation, and more. Icons outside this list are not available through the picker — extend `allIcons` in the component source if additional icons are required. + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `label` | `String` | `''` | Label shown on the activator text field | +| `items` | `Array` | `[]` | Unused; reserved for future extension | +| `itemValue` | `String` | `'id'` | Unused; reserved for future extension | +| `itemTitle` | `String` | `'name'` | Unused; reserved for future extension | +| `checkboxColor` | `String` | `'success'` | Unused; reserved for future extension | + +## Notes + +- The activator text field is `readonly`. The user must open the dialog to change the value. +- No preview of the selected icon is rendered in the text field itself — the raw string (e.g. `mdi-account`) is shown as plain text. diff --git a/docs/src/pages/guide/components/labs/input-otp.md b/docs/src/pages/guide/components/labs/input-otp.md new file mode 100644 index 000000000..481322d93 --- /dev/null +++ b/docs/src/pages/guide/components/labs/input-otp.md @@ -0,0 +1,26 @@ +--- +sidebarPos: 7 +sidebarTitle: Input OTP +--- + +# InputOtp <Badge type="warning" text="stub" /> + +`InputOtp` is a placeholder for a one-time password input field. The component renders a stub `<div>OTP</div>` and has no functional implementation yet. + +> [!WARNING] +> This component is not usable in production. It is listed here as a tracking entry for a planned input type. + +## Schema usage (planned) + +```php +[ + 'type' => 'otp', + 'name' => 'verification_code', + 'label' => 'Verification Code', + 'length' => 6, +] +``` + +## Status + +Implementation is pending. When ready, `InputOtp` will integrate with `useInput` and emit standard form events compatible with `ue-form`. diff --git a/docs/src/pages/guide/components/labs/input-range.md b/docs/src/pages/guide/components/labs/input-range.md new file mode 100644 index 000000000..9c42d0d17 --- /dev/null +++ b/docs/src/pages/guide/components/labs/input-range.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 3 +sidebarTitle: Input Range +--- + +# InputRange <Badge type="warning" text="experimental" /> + +`InputRange` wraps Vuetify's `v-range-slider` inside the `ue-form` schema system. It produces a two-handle slider for selecting a numeric range. + +## Schema usage + +```php +[ + 'type' => 'range', + 'name' => 'price_range', + 'label' => 'Price Range', + 'min' => 0, + 'max' => 10000, + 'step' => 100, +] +``` + +## Value format + +The model value is an array with exactly two numbers: `[min, max]`. + +```js +// example stored value +[200, 4500] +``` + +## Schema keys + +All keys on the schema object (besides `type` and `name`) are forwarded to `v-range-slider`. Refer to the [Vuetify v-range-slider documentation](https://vuetifyjs.com/en/components/range-sliders/) for the full list of supported props such as `min`, `max`, `step`, `thumb-label`, `color`, and `track-color`. + +## Notes + +- Initialise the model value as a two-element array; an uninitialised value (e.g. `null` or `[]`) will cause the slider to render with both handles at position 0. +- The `label` key is explicitly extracted and passed as the `:label` prop on the slider — it does not need to be nested inside any sub-object. diff --git a/docs/src/pages/guide/components/labs/input-time.md b/docs/src/pages/guide/components/labs/input-time.md new file mode 100644 index 000000000..1bb24d00e --- /dev/null +++ b/docs/src/pages/guide/components/labs/input-time.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 2 +sidebarTitle: Input Time +--- + +# InputTime <Badge type="warning" text="experimental" /> + +`InputTime` presents a read-only text field that opens a `v-time-picker` in a popover when clicked. The field value and the picker are both bound to the same model value. + +## Schema usage + +```php +[ + 'type' => 'time', + 'name' => 'starts_at', + 'label' => 'Start Time', + 'picker_props' => [ + 'format' => '24hr', + ], +] +``` + +## Schema keys + +| Key | Type | Description | +|---|---|---| +| `label` | `String` | Label shown on the text field | +| `picker_props` | `Object` | Props forwarded directly to `v-time-picker` (e.g. `format`, `min`, `max`, `use-seconds`) | +| Any other key | — | Forwarded to the backing `v-text-field` via `obj.schema` | + +## Value format + +The stored value is a time string in `HH:MM` (or `HH:MM:SS` with `use-seconds`) format, matching what `v-time-picker` emits on `@click:minute`. + +## Notes + +- The popover closes automatically when the user selects the minutes digit. +- The text field is `readonly` — users cannot type a time directly; they must use the picker. +- `picker_props` is bound via `$bindAttributes`, so Vuetify attribute inheritance rules apply. diff --git a/docs/src/pages/guide/components/labs/input-treeview.md b/docs/src/pages/guide/components/labs/input-treeview.md new file mode 100644 index 000000000..0bc6c3a5a --- /dev/null +++ b/docs/src/pages/guide/components/labs/input-treeview.md @@ -0,0 +1,67 @@ +--- +sidebarPos: 5 +sidebarTitle: Input Treeview +--- + +# InputTreeview <Badge type="warning" text="experimental" /> + +`InputTreeview` wraps Vuetify's `v-treeview` inside the `ue-form` schema system, enabling hierarchical item selection as a form field. + +## Schema usage + +```php +[ + 'type' => 'treeview', + 'name' => 'category_ids', + 'label' => 'Categories', + 'items' => $categories, // nested array — see structure below + 'item-title' => 'name', + 'item-value' => 'id', + 'selectable' => true, + 'open' => [], // array of initially open node IDs +] +``` + +## Items structure + +The `items` array must be a nested tree where each node can have a `children` key: + +```php +[ + [ + 'id' => 1, + 'name' => 'Technology', + 'children' => [ + ['id' => 2, 'name' => 'Software'], + ['id' => 3, 'name' => 'Hardware'], + ], + ], + [ + 'id' => 4, + 'name' => 'Science', + ], +] +``` + +## Value format + +The model value is an array of selected item values (e.g. IDs). In `selectable` mode, Vuetify handles intermediate states for parent nodes automatically. + +## Schema keys + +All keys on the schema object are forwarded to `v-treeview` via `v-bind="obj.schema"`. Commonly used keys: + +| Key | Description | +|---|---| +| `items` | The tree data array | +| `item-title` | Property used as the node label (default `title`) | +| `item-value` | Property used as the node value (default `value`) | +| `selectable` | Enables checkboxes for multi-selection | +| `open` | Array of initially expanded node IDs | +| `open-all` | Opens all nodes by default | +| `select-strategy` | `'leaf'`, `'independent'`, or `'classic'` selection behaviour | + +## Notes + +- Both `v-model` (selected values) and `v-model:active` are bound to the same `input` ref, which may not suit all use cases — use `select-strategy` to control the selection behaviour precisely. +- `v-model:open` is bound to `obj.schema.open`, so the open state is driven by the schema rather than a separate data property. diff --git a/docs/src/pages/guide/components/labs/overview.md b/docs/src/pages/guide/components/labs/overview.md new file mode 100644 index 000000000..d5c325554 --- /dev/null +++ b/docs/src/pages/guide/components/labs/overview.md @@ -0,0 +1,37 @@ +--- +sidebarPos: 99 +sidebarTitle: Overview +sidebarGroupTitle: Labs +--- + +# Labs <Badge type="warning" text="experimental" /> + +The **Labs** section contains components and input types that are experimental, in early development, or domain-specific. They are functional but may have incomplete APIs, limited test coverage, or may change without a semver notice. + +## Input types + +These components integrate with the `ue-form` schema system via `useInput`. Use their type name in a field schema to activate them. + +| Component | Schema type | Description | +|---|---|---| +| [`InputDate`](./input-date) | `date` | Native HTML date picker with ISO normalisation | +| [`InputTime`](./input-time) | `time` | Time picker via a `v-time-picker` popover | +| [`InputRange`](./input-range) | `range` | Dual-handle range slider | +| [`InputColor`](./input-color) | `color` | Hex color picker with swatch preview | +| [`InputTreeview`](./input-treeview) | `treeview` | Hierarchical tree selection | +| [`InputIcon`](./input-icon) | `icon` | MDI icon picker with search | +| [`InputOtp`](./input-otp) | `otp` | One-time password input *(stub)* | + +## Display / layout components + +| Component | Description | +|---|---| +| [`Callout`](./callout) | Bordered alert card with a title and value | +| [`RowFormat`](./row-format) | Flexible labelled row layout using a column array | + +## Specialised components + +| Component | Description | +|---|---| +| [`ActiveTableItem`](./active-table-item) | Modal-driven detail panel for a selected table row | +| [`PressReleaseCardIterator`](./press-release-card-iterator) | Card iterator row for press release data tables | diff --git a/docs/src/pages/guide/components/labs/press-release-card-iterator.md b/docs/src/pages/guide/components/labs/press-release-card-iterator.md new file mode 100644 index 000000000..f8bf3510f --- /dev/null +++ b/docs/src/pages/guide/components/labs/press-release-card-iterator.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 11 +sidebarTitle: Press Release Card Iterator +--- + +# PressReleaseCardIterator <Badge type="warning" text="experimental" /> + +`PressReleaseCardIterator` is a domain-specific card row component used as a custom iterator inside a `ue-table` that displays press release records. It renders a `ue-configurable-card` with a fixed four-column layout for package languages, content details, price, and status. + +> [!NOTE] +> This component is tightly coupled to the press release data model. It is not a general-purpose iterator. + +## Usage inside a data table + +Register it as the `row-iterator` on a `ue-table`: + +```html +<ue-table + :columns="columns" + :items="items" + row-iterator="press-release-card-iterator" +> + <template #actions> + <v-btn icon="mdi-pencil" @click="edit(item)" /> + </template> +</ue-table> +``` + +## Card layout + +| Segment | Content | +|---|---| +| Header | Press release ID (`item.id`) and headline (`item.content.headline`) | +| Segment 1 | Package → languages map rendered as a `ue-property-list` | +| Segment 2 | Content file, media images, and distribution date | +| Segment 3 | Price (`item._price`) | +| Segment 4 | Status string (`item._status`, defaults to `'Draft'`) | +| Actions | Delegated to the `#actions` slot | + +## Expected item shape + +```js +{ + id: 123, + name: 'Q1 Announcement', + content: { + headline: 'Company announces Q1 results', + file: 'release.pdf', + press_release_images: [{ image: 'photo.jpg' }], + date: '2025-04-17', + }, + press_release_packages: { + pkg1: { + name: 'Premium', + packageLanguages: [{ name: 'English' }, { name: 'German' }], + }, + }, + _price: '$2,500', + _status: 'Published', +} +``` + +## Slots + +| Slot | Description | +|---|---| +| `actions` | Rendered inside the card's actions segment. Use for edit, delete, or view buttons. | + +## Props + +Props are defined by `makeTableIteratorProps()` from `@/hooks/table`. Standard iterator props include the `item` object and any formatter configuration. `ignoreFormatters` is accepted and defaults to excluding the `activate` formatter. diff --git a/docs/src/pages/guide/components/labs/row-format.md b/docs/src/pages/guide/components/labs/row-format.md new file mode 100644 index 000000000..61461a808 --- /dev/null +++ b/docs/src/pages/guide/components/labs/row-format.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 9 +sidebarTitle: Row Format +--- + +# RowFormat <Badge type="warning" text="experimental" /> + +`RowFormat` renders a single `v-row` containing one `v-col` per element in the `elements` array. Each column holds a `<label>` with configurable text, class, and style. A shared `color` prop applies a Vuetify text-colour class to every label. + +It is used internally by [`Callout`](./callout) to lay out its title/value columns. + +## Usage + +```html +<ue-row-format + :elements="[ + { text: 'Revenue', col: { cols: 8 } }, + { text: '$12,400', col: { cols: 4 }, style: { 'font-size': '1.5rem' } }, + ]" + color="success" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `elements` | `Array` | `[]` | Array of column definitions. See [Element object](#element-object). | +| `color` | `String` | `''` | Vuetify colour token applied as `text-{color}` to every label. Empty string applies no class. | + +## Element object + +Each entry in `elements` is a plain object: + +| Key | Type | Required | Description | +|---|---|---|---| +| `text` | `String` | Yes | Label text content | +| `col` | `Object` | No | Props forwarded to `v-col` (e.g. `{ cols: 6, md: 4 }`) | +| `class` | `String` | No | Additional CSS classes on the `<label>` | +| `style` | `Object` | No | Inline styles on the `<label>` (e.g. `{ 'font-size': '1.5rem' }`) | + +## Colour logic + +When `color` is set to a non-empty string the class `text-{color}` is added to every `<label>`. Individual columns cannot override this — use the `class` key on an element only for non-colour classes. diff --git a/docs/src/pages/guide/components/layouts/footer.md b/docs/src/pages/guide/components/layouts/footer.md new file mode 100644 index 000000000..b3fb6c6ec --- /dev/null +++ b/docs/src/pages/guide/components/layouts/footer.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 6 +sidebarTitle: Footer (legacy) +--- + +# Footer <Badge type="danger" text="legacy" /> + +`Footer` is a legacy prototype component. It renders a dark `v-footer` with a row of icon-link buttons and placeholder Lorem Ipsum text. It is **not used in production**. + +> [!WARNING] +> Do not use this component. It uses Vuetify 2 props (`dark`, `padless`, class-based colour tokens) that are not compatible with Vuetify 3. + +## Props + +| Prop | Type | Required | Default | Description | +|---|---|---|---|---| +| `items` | `Array` | Yes | — | Array of link objects. Each must have `icon` (MDI icon string) and `url` (href). | +| `show` | `Boolean` | No | `true` | Controls footer visibility via `v-show`. | + +## Example items shape + +```js +[ + { icon: 'mdi-facebook', url: 'https://facebook.com' }, + { icon: 'mdi-twitter', url: 'https://twitter.com' }, +] +``` + +## Notes + +- The footer body text is hardcoded Lorem Ipsum and is not configurable. +- The copyright year is rendered dynamically via `new Date().getFullYear()`. +- This component has no connection to the main application store or Modularous config. diff --git a/docs/src/pages/guide/components/layouts/home.md b/docs/src/pages/guide/components/layouts/home.md new file mode 100644 index 000000000..40810b648 --- /dev/null +++ b/docs/src/pages/guide/components/layouts/home.md @@ -0,0 +1,27 @@ +--- +sidebarPos: 5 +sidebarTitle: Home (legacy) +--- + +# Home <Badge type="danger" text="legacy" /> + +`Home` is a legacy prototype layout component. It renders a `v-app` containing only a `Sidebar` with five hardcoded icon items. It is **not used in production** and exists only as an early development artifact. + +> [!WARNING] +> Do not use this component. Use [`ue-main`](./main) for all application layouts. + +## What it does + +```html +<v-app id="inspire" :style="{background: $vuetify.theme.themes.dark.background}"> + <Sidebar :items="[ + {icon: 'fas fa-plus'}, + {icon: 'fas fa-th-large'}, + {icon: 'fas fa-align-center'}, + {icon: 'fas fa-gitter'}, + {icon: 'fas fa-chart-line'}, + ]" /> +</v-app> +``` + +It uses Vuetify 2 theme syntax (`$vuetify.theme.themes.dark.background`) which is no longer valid in Vuetify 3. diff --git a/docs/src/pages/guide/components/layouts/main.md b/docs/src/pages/guide/components/layouts/main.md new file mode 100644 index 000000000..829542e93 --- /dev/null +++ b/docs/src/pages/guide/components/layouts/main.md @@ -0,0 +1,147 @@ +--- +sidebarPos: 1 +sidebarTitle: Main +--- + +# Main (`ue-main`) + +`ue-main` is the root application shell. It wraps `v-app` and composes the top bar, sidebar, page content area, optional bottom navigation, and a set of singleton modals that are available everywhere in the app. + +## Usage + +Pass the `navigation` object from your Inertia/backend page props: + +```html +<ue-main + :navigation="navigation" + header-title="My App" + :impersonation="impersonation" + :authorization="authorization" +> + <!-- page content --> +</ue-main> +``` + +The `navigation` prop is typically shared via Inertia's `HandleInertiaRequests` middleware and has this shape: + +```php +'navigation' => [ + 'sidebar' => $this->buildSidebarItems(), + 'sidebarBottom' => [], + 'profileMenu' => $this->buildProfileMenu(), + 'breadcrumbs' => [], +] +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `navigation` | `Object` | `{ sidebar: [], breadcrumbs: [], profileMenu: [], sidebarBottom: [] }` | Navigation data. Keys: `sidebar`, `sidebarBottom`, `profileMenu`, `breadcrumbs`. | +| `headerTitle` | `String` | `''` | Text shown in the centre of the top app bar. | +| `hideDefaultSidebar` | `Boolean` | `false` | Omit the sidebar entirely (e.g. for login/auth pages). | +| `fixedAppBar` | `Boolean` | `false` | Force the top bar to render regardless of `ui_settings`. | +| `appBarOrder` | `Number` | `0` | Vuetify `order` for the `v-app-bar` when `fixedAppBar` is `true`. | +| `sidebarAttributes` | `Object` | `{}` | Extra props forwarded to `ue-sidebar`. | +| `impersonation` | `Object` | `{}` | Config for the impersonation toolbar. See [Impersonation](#impersonation). | +| `authorization` | `Object` | `{}` | Auth flags used to gate the media library. See [Authorization](#authorization). | + +## Slots + +| Slot | Description | +|---|---| +| `default` | Main page content. Rendered inside `v-main`. | +| `top` | Content rendered above the default slot inside `v-main`. | +| `bottom` | Content rendered below the default slot inside `v-main`. | +| `app-bar` | Replaces the entire default `v-app-bar` inner content. Receives no bindings. | +| `bottom-nav` | Replaces the default items inside `v-bottom-navigation` (mobile). | + +## Navigation prop + +| Key | Type | Description | +|---|---|---| +| `sidebar` | `Array` | Items passed to `ue-sidebar` / `ue-navigation-group`. | +| `sidebarBottom` | `Array` | Items pinned to the bottom of the sidebar via `ue-navigation-group`. | +| `profileMenu` | `Array` | Items for the collapsible profile menu in the sidebar footer. | +| `breadcrumbs` | `Array` | Reserved; not currently consumed by `Main.vue` directly. | + +## Top bar + +The top bar is a `v-app-bar` that renders when `showTopbar` is `true`. The value is derived from `useNavigationLayout()` (which reads `ui_settings.topbar`) unless `fixedAppBar` is `true`, which forces it on. + +Default top-bar content: +- **Hamburger icon** — shown on screens below `lg`; toggles the sidebar via `$toggleSidebar()`. +- **Title** — centred, driven by `headerTitle` prop. +- **Avatar** — shows the current user's avatar; clicking opens the profile dialog. + +Override the entire bar using `#app-bar`. + +## Bottom navigation + +A `v-bottom-navigation` is rendered when `showBottomNav` is `true` (resolved from `useNavigationLayout()`). Default items are a **Home** button and a **Profile** button. Override with `#bottom-nav`. + +## Singleton modals + +`ue-main` mounts the following modals once for the entire application: + +| Modal | Trigger | Description | +|---|---|---| +| `ue-modal-media` | `store.state.mediaLibrary.showModal` | Full media library browser | +| Profile dialog | `store.state.user.profileDialog` / `$openProfileDialog` | Avatar upload form | +| Alert dialog | `store.state.alert.dialog` | Large-format dialog alert | +| Login modal | `store.state.user.showLoginModal` | Session-expired re-login form | +| `ue-alert` | `store.state.alert` | Snackbar/toast notifications | +| `ue-dynamic-modal` | programmatic API | Dynamically triggered modals | + +## Impersonation + +Pass an `impersonation` object to show the impersonation toolbar at the bottom of the sidebar: + +```php +'impersonation' => [ + 'active' => auth()->user()->isImpersonating(), + 'fetchEndpoint' => route('impersonate.users'), + 'route' => route('impersonate.start', ':id'), + 'stopRoute' => route('impersonate.stop'), +] +``` + +| Key | Type | Description | +|---|---|---| +| `active` | `Boolean` | Shows the toolbar when `true` | +| `fetchEndpoint` | `String` | API endpoint to search users | +| `route` | `String` | URL template for starting impersonation (`:id` is replaced) | +| `stopRoute` | `String` | URL to stop impersonation | + +## Authorization + +The `authorization` object controls whether the media library modal is mounted: + +```php +'authorization' => [ + 'isClient' => $user->isClient(), +] +``` + +The media library is accessible when `authorization` is a non-empty object and `isClient` is `false`. + +## CSS classes on `v-app` + +`Main.vue` applies these classes to the root `v-app` element based on sidebar state: + +| Class | Condition | +|---|---| +| `ue-sidebar-expanded` | Desktop, sidebar open and not rail-only | +| `ue-sidebar-rail-only` | Desktop, rail mode active and sidebar open | +| `ue-sidebar-fully-hidden` | `expandHover === 'hidden'` and sidebar not pinned | + +CSS custom properties set on `v-app`: + +| Property | Set when | +|---|---| +| `--ue-sidebar-width` | Sidebar is expanded; value is the configured drawer width in px | +| `--ue-sidebar-rail-width` | Sidebar is in rail-only mode; value is the rail width in px | + +## Development mode banner + +When `store.getters.isHot` is `true` (HMR active), a green **"Development Mode"** chip is shown in the top-right corner of the screen. Clicking it dismisses it. diff --git a/docs/src/pages/guide/components/layouts/overview.md b/docs/src/pages/guide/components/layouts/overview.md new file mode 100644 index 000000000..96cd144df --- /dev/null +++ b/docs/src/pages/guide/components/layouts/overview.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 38 +sidebarTitle: Overview +sidebarGroupTitle: Layouts +--- + +# Layouts + +Modularous provides a layered application shell built from six components. In practice you only configure [`ue-main`](./main) — the rest are assembled automatically. + +## Component hierarchy + +``` +ue-main (Main.vue) +├── v-app-bar — optional top bar +├── ue-sidebar (Sidebar.vue) +│ └── ue-sidebar-content (SidebarContent.vue) +│ ├── ue-sidebar-drawer-content (SidebarDrawerContent.vue) +│ │ ├── [prepend] app name + logo + rail toggle +│ │ ├── ue-navigation-group — nav items +│ │ └── [append] user info, profile menu, logout, About +│ └── resize handle (drag to resize) +├── v-main — page content slot +├── v-bottom-navigation — optional mobile nav bar +└── singleton modals + ├── ue-modal-media — media library + ├── ue-modal (profile) — profile image upload + ├── ue-modal (alert) — dialog-style alerts + ├── ue-modal (login) — session-expired re-login + ├── ue-alert — snackbar/toast alerts + └── ue-dynamic-modal — programmatically triggered modals +``` + +## Components + +| Component | Tag | Role | +|---|---|---| +| [`Main`](./main) | `ue-main` | Top-level app shell — the only component you configure directly | +| [`Sidebar`](./sidebar) | `ue-sidebar` | Navigation drawer with hover-zone support for fully-hidden mode | +| [`SidebarContent`](./sidebar-content) | `ue-sidebar-content` | Adds resize handle and secondary drawer slots around the drawer | +| [`SidebarDrawerContent`](./sidebar-drawer-content) | `ue-sidebar-drawer-content` | The `v-navigation-drawer` — header, nav list, user footer | +| [`Home`](./home) | — | Legacy prototype layout *(not for production use)* | +| [`Footer`](./footer) | — | Legacy prototype footer *(not for production use)* | + +## Sidebar display modes + +The sidebar behaviour is driven by `ui_settings` in the Modularous config and the user's saved preferences. The four effective modes are: + +| Mode | Description | +|---|---| +| **Persistent expanded** | Full-width drawer, always visible on desktop | +| **Rail** | Icon-only strip; expands on hover (`expandHover: 'mini'`) | +| **Fully hidden** | Completely off-screen; a hover zone on the left edge triggers it (`expandHover: 'hidden'`) | +| **Temporary** | Overlay drawer (default on mobile) | + +You do not pass these modes as props — they are resolved from store state inside `useSidebar()`. diff --git a/docs/src/pages/guide/components/layouts/sidebar-content.md b/docs/src/pages/guide/components/layouts/sidebar-content.md new file mode 100644 index 000000000..c4a05228f --- /dev/null +++ b/docs/src/pages/guide/components/layouts/sidebar-content.md @@ -0,0 +1,82 @@ +--- +sidebarPos: 3 +sidebarTitle: Sidebar Content +--- + +# SidebarContent (`ue-sidebar-content`) + +`SidebarContent` is an internal wrapper that sits between [`Sidebar`](./sidebar) and [`SidebarDrawerContent`](./sidebar-drawer-content). Its responsibilities are: + +1. **Rendering the drawer** — passes all sidebar state props down to `ue-sidebar-drawer-content`. +2. **Resize handle** — adds an 8 px drag strip between the drawer and the main content area so users can resize the sidebar width. +3. **Secondary / content drawers** — conditionally mounts additional `v-navigation-drawer` instances when `options.contentDrawer` or `secondaryOptions` are configured. + +> [!NOTE] +> This is an internal component. You do not use it directly. + +## Resize handle + +The resize handle is only shown when all of these are true: + +- Sidebar is not in rail mode (`!rail`) +- Sidebar is open (`status === true`) +- Not in temporary (overlay) mode (`!effectiveTemporary`) +- Screen is desktop size (`$vuetify.display.lgAndUp`) + +The handle is 8 px wide and uses `cursor: col-resize`. It highlights in `rgba(primary, 0.4)` on hover or while dragging. Dragging emits `resize-start` with the `mousedown` event, which is handled by `useSidebar`. + +The handle's `left` (or `right` in right-to-left layouts) position tracks the current drawer `width` in px: + +``` +left: {width}px (ltr sidebar) +right: {width}px (rtl sidebar, sidebarLocation === 'right') +``` + +## Secondary drawers + +Two optional `v-navigation-drawer` instances are mounted when their configuration objects exist: + +| Condition | Description | +|---|---| +| `options.contentDrawer?.exists` | A content-panel drawer, max-width 15%, location driven by `options.location` | +| `secondaryOptions?.exists` | A secondary navigation drawer at `secondaryOptions.location` | + +Both are pass-through — their content is not managed by this component. + +## Props + +| Prop | Type | Required | Description | +|---|---|---|---| +| `items` | `Array` | Yes | Forwarded to `ue-sidebar-drawer-content` | +| `profileMenu` | `Array` | No | Forwarded to `ue-sidebar-drawer-content` | +| `miniSymbol` | `String\|Object` | No | Forwarded to `ue-sidebar-drawer-content` | +| `profileMenuOpen` | `Boolean` | No | Two-way bound with `ue-sidebar-drawer-content` | +| `status` | `Boolean` | Yes | Drawer open/closed state | +| `rail` | `Boolean` | Yes | Rail (icon-only) mode | +| `isHoverable` | `Boolean` | Yes | Vuetify expand-on-hover | +| `hideIcons` | `Boolean` | Yes | Text-only navigation mode | +| `options` | `Object` | Yes | Sidebar config object (location, railWidth, contentDrawer, etc.) | +| `width` | `Number\|String` | Yes | Drawer width in px | +| `effectivePersistent` | `Boolean` | Yes | Vuetify persistent prop | +| `effectivePermanent` | `Boolean` | Yes | Vuetify permanent prop | +| `effectiveTemporary` | `Boolean` | No | Vuetify temporary (overlay) prop | +| `railManual` | `Boolean` | Yes | Whether rail was set manually by the user | +| `secondaryOptions` | `Object` | No | Config for the secondary drawer | +| `isResizing` | `Boolean` | No | Adds `ue-sidebar-resize-active` class to the handle | +| `sidebarLocation` | `String` | No | `'left'` or `'right'`; controls handle position | + +## Emits + +| Event | Description | +|---|---| +| `update:status` | Forwarded from `ue-sidebar-drawer-content` | +| `update:profileMenuOpen` | Forwarded from `ue-sidebar-drawer-content` | +| `activate-menu` | Forwarded from `ue-sidebar-drawer-content` | +| `rail-toggle` | Forwarded from `ue-sidebar-drawer-content` | +| `resize-start` | Emitted on resize handle `mousedown` | + +## Slots + +| Slot | Description | +|---|---| +| `bottom` | Passed through to `ue-sidebar-drawer-content` | diff --git a/docs/src/pages/guide/components/layouts/sidebar-drawer-content.md b/docs/src/pages/guide/components/layouts/sidebar-drawer-content.md new file mode 100644 index 000000000..484345afe --- /dev/null +++ b/docs/src/pages/guide/components/layouts/sidebar-drawer-content.md @@ -0,0 +1,104 @@ +--- +sidebarPos: 4 +sidebarTitle: Sidebar Drawer Content +--- + +# SidebarDrawerContent (`ue-sidebar-drawer-content`) + +`SidebarDrawerContent` is the innermost layout component — the actual `v-navigation-drawer`. It renders the logo/app header, the navigation list, and the user footer with profile menu, logout, and About dialog. + +> [!NOTE] +> This is an internal component. You do not use it directly. + +## Layout + +``` +┌─────────────────────────────────────┐ +│ [prepend] │ +│ ┌────────────────────────────────┐ │ +│ │ mini-logo App Name │ │ ← app info header +│ │ app@email.com ◀◀ │ │ ← rail toggle (desktop only) +│ └────────────────────────────────┘ │ +│ ───────────────────────────────── │ +│ │ +│ ue-navigation-group (items) │ ← main nav list +│ │ +│ [append] │ +│ ───────────────────────────────── │ +│ ┌────────────────────────────────┐ │ +│ │ avatar User Name ▼ │ │ ← user info + profile toggle +│ │ user@email.com │ │ +│ └────────────────────────────────┘ │ +│ [ue-navigation-group profileMenu] │ ← collapsible profile menu +│ Logout │ +│ About │ +│ [bottom slot] │ ← impersonation toolbar, etc. +└─────────────────────────────────────┘ +``` + +## Prepend — app header + +- Displays the app name and email from `store.getters.appName` / `store.getters.appEmail`. +- The avatar shows the `miniSymbol` SVG icon. +- A **rail toggle** button (`mdi-chevron-double-left` / `mdi-chevron-double-right`) is shown on desktop when `lgAndUp` is true. Clicking emits `rail-toggle`. + - When `railManual` is `true` (drawer is manually collapsed) the icon points right; otherwise left. + +## Body — navigation + +Renders `ue-navigation-group` with: +- `items` — the full nav tree +- `hideIcons` — switches to text-only mode +- `showTooltip` — enabled when in rail mode without hover expand (`rail && !isHoverable`), so hovering a collapsed item shows its label in a tooltip + +## Append — user footer + +Visible only when the user is authenticated (`!store.getters.isGuest`): + +| Element | Description | +|---|---| +| Avatar | Shows `userProfile.avatar_url`; clicking calls `$openProfileDialog` | +| Name / email | From `userProfile.name` and `userProfile.email`; email has a tooltip | +| Profile toggle | `mdi-chevron-down/up` button; emits `update:profileMenuOpen` | +| Profile menu | `ue-navigation-group` with `profileMenu` items; shown in a `v-expand-transition` | +| Logout | `ue-logout-modal` with CSRF token; shows tooltip when in collapsed rail mode | +| About | `v-dialog` showing package versions, app name, env, and debug state (superadmin only) | + +The `bottom` slot is rendered after the user section (used for impersonation toolbar etc.). + +## Props + +| Prop | Type | Required | Default | Description | +|---|---|---|---|---| +| `items` | `Array` | Yes | — | Main navigation items | +| `profileMenu` | `Array` | No | `[]` | Profile popover navigation items | +| `miniSymbol` | `String` | No | `'main-logo-dark'` | SVG symbol for the prepend avatar | +| `profileMenuOpen` | `Boolean` | No | `false` | Controls the profile menu expand state | +| `status` | `Boolean` | Yes | — | Drawer open/closed (`v-navigation-drawer` `model-value`) | +| `rail` | `Boolean` | Yes | — | Icon-only rail mode | +| `isHoverable` | `Boolean` | Yes | — | `expand-on-hover` on the drawer | +| `hideIcons` | `Boolean` | Yes | — | Passed to `ue-navigation-group` | +| `options` | `Object` | Yes | — | Contains `location`, `railWidth`, etc. | +| `width` | `Number\|String` | Yes | — | Drawer width | +| `effectivePersistent` | `Boolean` | Yes | — | Vuetify `persistent` | +| `effectivePermanent` | `Boolean` | Yes | — | Vuetify `permanent` | +| `effectiveTemporary` | `Boolean` | No | `false` | Vuetify `temporary` | +| `railManual` | `Boolean` | Yes | — | Whether the user manually collapsed to rail | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `update:status` | `Boolean` | Drawer open/close state change | +| `update:profileMenuOpen` | `Boolean` | Profile menu expand toggle | +| `activateMenu` | event | Forwarded from `ue-navigation-group` profile menu | +| `rail-toggle` | — | Rail toggle button clicked | + +## Slots + +| Slot | Description | +|---|---| +| `bottom` | Appended after the user footer section in `[append]` | + +## About dialog + +The **About** entry is only visible when `store.getters.versions` is populated and the user is not a guest or a client. For superadmins it additionally shows `appName`, `appEnv`, and `appDebug` with colour-coded chips. diff --git a/docs/src/pages/guide/components/layouts/sidebar.md b/docs/src/pages/guide/components/layouts/sidebar.md new file mode 100644 index 000000000..6ac73e072 --- /dev/null +++ b/docs/src/pages/guide/components/layouts/sidebar.md @@ -0,0 +1,69 @@ +--- +sidebarPos: 2 +sidebarTitle: Sidebar +--- + +# Sidebar (`ue-sidebar`) + +`ue-sidebar` is the navigation drawer component. It reads sidebar state from `useSidebar()` and renders [`SidebarContent`](./sidebar-content) either directly or inside a **fully-hidden hover wrapper**, depending on the active display mode. + +> [!NOTE] +> You do not use `ue-sidebar` directly. It is mounted by [`ue-main`](./main) via the `hideDefaultSidebar` prop. + +## Fully-hidden mode + +When `expandHover` is set to `'hidden'` in `ui_settings`, the sidebar is completely off-screen. A transparent **hover zone** (12 px wide) sits flush against the left edge of the viewport. On desktop (`lgAndUp`), hovering over this zone commits `CONFIG.SET_SIDEBAR = true` to the store, which causes the drawer to slide in. + +``` +┌──────────────────────────────────────────────┐ +│░ (hover zone, 12px) │ +│░ ← mouse enters here → sidebar opens │ +└──────────────────────────────────────────────┘ +``` + +Moving the mouse outside the wrapper triggers `handleSidebarLeave`, which closes the drawer again. + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `items` | `Array` | required | Navigation items forwarded to `ue-navigation-group`. | +| `profileMenu` | `Array` | `[]` | Profile popover items shown in the sidebar footer. | +| `logoSymbol` | `String` | `'main-logo-dark'` | SVG symbol name for the sidebar logo. The mini symbol is resolved automatically via `getLocaleSymbol`. | +| `rating` | `Number` | `0` | Reserved; not currently used in the template. | + +## Slots + +| Slot | Description | +|---|---| +| `bottom` | Content appended at the very bottom of the drawer (below the user footer). Passed through to `SidebarDrawerContent`. Used by `ue-main` for `ue-navigation-group` bottom items and `ue-impersonate-toolbar`. | + +## Exposed methods + +| Method | Description | +|---|---| +| `profileFormSubmitted(res)` | Called by `ue-main` after a profile save; re-fetches the user profile from `URLS.profileShow`. | + +## Sidebar state (from `useSidebar`) + +`ue-sidebar` consumes the following reactive values from `useSidebar()`. These are not props — they are driven entirely by the store and `ui_settings`: + +| Value | Description | +|---|---| +| `fullyHidden` | Enables the hover-zone wrapper | +| `hoverZoneWidth` | Width (px) of the transparent hover strip | +| `status` | Whether the drawer is open | +| `rail` | Whether the drawer is in icon-only rail mode | +| `isHoverable` | Enables Vuetify's built-in `expand-on-hover` | +| `hideIcons` | Hides nav icons (text-only mode) | +| `width` | Drawer width in px | +| `effectivePersistent / Permanent / Temporary` | Vuetify drawer behaviour props | +| `railManual` | Whether the user has manually collapsed to rail | + +## Active menu + +`ue-sidebar` provides `activeMenu` (from `useSidebar`) to all descendants via Vue's `provide/inject` API under the key `'activeMenu'`. Navigation group items use this to highlight the active route. + +## Auto-scroll to active item + +On `onMounted`, the component scrolls `.sidebar-item-active` into view within `.v-navigation-drawer__content` using `useGoTo` with 200 ms easing. diff --git a/docs/src/pages/guide/components/list-section.md b/docs/src/pages/guide/components/list-section.md new file mode 100644 index 000000000..b1a9f2a20 --- /dev/null +++ b/docs/src/pages/guide/components/list-section.md @@ -0,0 +1,98 @@ +--- +sidebarPos: 15 +sidebarTitle: List Section +--- +# List Section + +`ue-list-section` is a flexible, multi-column list component that renders rows from an array of items. It supports optional headers, column width control, striped/hoverable rows, a "show more/less" collapse feature, an optional expansion-panel wrapper, and a per-row actions slot. + +## Usage + +```html +<ue-list-section + :items="users" + :item-fields="['name', 'email', 'role']" + :headers="['Name', 'Email', 'Role']" + show-header +/> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `items` | `Array` | required | Array of data objects to render | +| `itemFields` | `Array` | `['name']` | Dot-notation field paths to display per column | +| `headers` | `Array` | `null` | Column header labels. Defaults to title-cased field names | +| `showHeader` | `Boolean` | `false` | Show the header row | +| `title` | `String` | — | Optional title above the list | +| `titleTag` | `String` | `'h3'` | HTML element for the title | +| `titleClasses` | `String` | `'text-body-1 font-weight-medium'` | Classes applied to the title | +| `itemClasses` | `String` | `'text-body-2'` | Classes applied to each data row | +| `headerClasses` | `String` | `'text-body-2 font-weight-bold'` | Classes applied to the header row | +| `colClasses` | `Array` | `[]` | Per-column CSS class array | +| `colWidths` | `Array` | `[]` | Fixed widths per column, e.g. `['120px', '1fr']` | +| `colRatios` | `Array` | `[]` | Flex ratios per column, e.g. `[2, 1, 1]` | +| `actionsHeader` | `String` | `''` | Header label for the actions column | +| `striped` | `Boolean` | `false` | Alternate row background colors | +| `hoverable` | `Boolean` | `false` | Highlight rows on hover | +| `hasRowBottomBorder` | `Boolean` | `false` | Add a bottom border to each row | +| `verticalAlignTop` | `Boolean` | `false` | Align cell content to the top | +| `emptyMessage` | `String` | `'No items to display'` | Message shown when `items` is empty | +| `rowClassFn` | `Function` | `null` | `(item, index) => String` — return extra classes per row | +| `dividerAttributes` | `Object` | `{}` | Attributes forwarded to `v-divider` for divider rows | +| `collapsible` | `Boolean` | `false` | Wrap the entire list in an expansion panel | +| `collapseLimit` | `Number` | `null` | Auto-wrap in expansion panel when item count exceeds this value | +| `shrinkAfter` | `Number` | `20` | Number of items shown before "show more" button appears | +| `showMoreText` | `String` | `'Show more'` | Label for the expand trigger | +| `shrinkText` | `String` | `'Show less'` | Label for the collapse trigger | +| `moreItemsText` | `String` | `'more items'` | Suffix label next to the hidden item count | +| `modelValue` | `Array\|String\|Number` | — | Controls the expansion panel open state externally | + +## Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `field.{n}` | `{value, item, index}` | Override cell content for column `n` (0-based) | +| `header.{n}` | `{header}` | Override header cell for column `n` | +| `row-actions` | `{item, index}` | Append an actions column to every row | +| `actions-header` | — | Header for the actions column | +| `title-content` | — | Custom title markup (used with `collapsible`) | +| `before-items` | — | Content injected before the first row | +| `after-items` | — | Content injected after the last row | + +## Divider Rows + +Insert a divider between items by adding `{ _type: 'divider' }` to the `items` array: + +```js +const items = [ + { name: 'Alice', role: 'Admin' }, + { _type: 'divider' }, + { name: 'Bob', role: 'Editor' }, +] +``` + +## Example — Compact Table with Actions + +```html +<ue-list-section + :items="orders" + :item-fields="['reference', 'total', 'status']" + :headers="['Ref', 'Total', 'Status']" + show-header + striped + hoverable + :col-ratios="[2, 1, 1]" + :shrink-after="10" +> + <template #field.2="{ value }"> + <v-chip :color="value === 'paid' ? 'success' : 'warning'" size="small"> + {{ value }} + </v-chip> + </template> + <template #row-actions="{ item }"> + <v-btn icon="mdi-eye" size="small" variant="text" :href="`/orders/${item.id}`" /> + </template> +</ue-list-section> +``` diff --git a/docs/src/pages/guide/components/logout-modal.md b/docs/src/pages/guide/components/logout-modal.md new file mode 100644 index 000000000..a891e3508 --- /dev/null +++ b/docs/src/pages/guide/components/logout-modal.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 34 +sidebarTitle: Logout Modal +--- +# Logout Modal + +`ue-logout-modal` is a confirmation dialog that submits a Laravel logout POST request on confirm. The activator button can be replaced via slot. + +## Usage + +```html +<!-- Default activator (a red "Logout" button) --> +<ue-logout-modal /> + +<!-- Custom activator --> +<ue-logout-modal> + <template #activator="{ props }"> + <v-list-item v-bind="props" title="Sign out" prepend-icon="mdi-logout" /> + </template> +</ue-logout-modal> +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `csrf` | `String` | CSRF token injected into the hidden `_token` form field. Falls back to `$csrf()` if omitted | + +## Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `activator` | `{ props }` | Replace the default logout button. Bind `props` to the element that should open the modal | + +## Behaviour + +- Dialog title, description, and button labels are resolved through the i18n keys `authentication.logout-title`, `authentication.logout-description`, `authentication.logout-cancel`, and `authentication.logout-confirm`. +- On confirm, a standard HTML form `POST /logout` is submitted with the CSRF token. +- The modal width is `md`. diff --git a/docs/src/pages/guide/components/markdown-render.md b/docs/src/pages/guide/components/markdown-render.md new file mode 100644 index 000000000..51d959a15 --- /dev/null +++ b/docs/src/pages/guide/components/markdown-render.md @@ -0,0 +1,36 @@ +--- +sidebarPos: 18 +sidebarTitle: Markdown Render +--- +# Markdown Render + +`ue-markdown-render` converts a Markdown string to HTML using [marked](https://marked.js.org/) and renders it in a styled container. When the document contains headings, an auto-generated sticky table of contents appears in a right-side column. + +## Usage + +```html +<ue-markdown-render :markdown="article.body" /> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `markdown` | `String` | yes | Raw Markdown string to render | + +## Features + +- **Heading anchors** — every `h1`–`h6` gets a slug-based `id` attribute for deep linking. +- **Table of contents** — headings up to `h3` are extracted and shown in a sticky right-side nav on `md+` screens. The TOC is hidden when no headings are present. +- **Slug deduplication** — duplicate heading texts get `-2`, `-3`, … suffixes automatically. +- **GitHub-style prose styles** — code blocks, blockquotes, lists, and inline code are styled to match GitHub Markdown. + +## Example — Render a CMS Page Body + +```php +<ue-markdown-render :markdown='@json($page->body)' /> +``` + +::: warning Sanitization +`ue-markdown-render` renders raw HTML via `v-html`. Never pass untrusted user-generated content without sanitizing it first on the server side. +::: diff --git a/docs/src/pages/guide/components/metric.md b/docs/src/pages/guide/components/metric.md new file mode 100644 index 000000000..7890dc745 --- /dev/null +++ b/docs/src/pages/guide/components/metric.md @@ -0,0 +1,148 @@ +--- +sidebarPos: 14 +sidebarTitle: Metric / Metrics / MetricGroups +--- +# Metric, Metrics & MetricGroups + +Three related components for displaying KPI-style numeric cards. Use `ue-metric` for a single value, `ue-metrics` for a grouped collection with an optional date-range filter, and `ue-metric-groups` for multiple `ue-metrics` groups laid out in a grid. + +--- + +## `ue-metric` + +A single KPI card showing a large value and a label. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `value` | `Number\|String` | required | The numeric or text value to display | +| `label` | `String` | required | Descriptive label shown below the value | +| `color` | `String` | `null` | Color applied to both value and label text | +| `cardColor` | `String` | `null` | Background color of the card | +| `labelColor` | `String` | `null` | Override label text color independently | +| `valueClass` | `String` | `''` | Extra classes on the value element | +| `labelClass` | `String` | `''` | Extra classes on the label element | +| `dense` | `Boolean` | `false` | Compact mode — smaller text (`text-h4` vs `text-h3`) | +| `noInline` | `Boolean` | `false` | Render as block instead of inline-block | +| `center` | `Boolean` | `false` | Center-align the content | +| `icon` | `String` | `null` | MDI icon shown before the label | +| `appendIcon` | `String` | `null` | MDI icon shown to the left of the value/label block | +| `appendIconAttributes` | `Object` | `{}` | Attributes forwarded to the `appendIcon` `v-icon` | + +### Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `value` | `{value, classes}` | Replaces the value element | +| `label` | `{label, classes, icon}` | Replaces the label element | + +### Example + +```html +<ue-metric value="1,248" label="Total Orders" color="primary" /> +<ue-metric value="98.5%" label="Uptime" color="success" dense /> +``` + +--- + +## `ue-metrics` + +A card that renders a collection of `ue-metric` items in a flex row, with an optional title, subtitle, and date-range filter. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `title` | `String` | required | Card title | +| `subtitle` | `String` | `null` | Caption below the title | +| `items` | `Array` | `[]` | Array of metric objects — each is spread as props into `ue-metric` | +| `color` | `String` | `null` | Text color for all metrics | +| `cardColor` | `String` | `null` | Card background color | +| `filterColor` | `String` | `null` | Color for the date-range filter area | +| `bgHeaderColor` | `String` | `null` | Header background color | +| `noInline` | `Boolean` | `false` | Render card as block | +| `metricWidth` | `String\|Number` | `null` | Fixed width for each metric | +| `minMetricWidth` | `String\|Number` | `130` | Minimum width (px) for each metric | +| `metricAttributes` | `Object` | `{}` | Extra attributes forwarded to every `ue-metric` | +| `endpoint` | `String` | `null` | API endpoint for date-range refresh. Required to activate the date filter | +| `dateLabel` | `String` | `'Today'` | Label shown next to the date | +| `date` | `String` | `null` | Date string displayed alongside the filter | + +### Item object shape + +```js +{ + value: '1,248', + label: 'Orders', + color: 'primary', // optional, overrides group color + connectorFilter: { // optional, marks this metric as filterable + name: 'date_range', + args: {} + } +} +``` + +### Example + +```php +@php + $metrics = [ + ['value' => $totalOrders, 'label' => 'Total Orders'], + ['value' => $revenue, 'label' => 'Revenue', 'color' => 'success'], + ['value' => $pendingCount, 'label' => 'Pending', 'color' => 'warning'], + ]; +@endphp + +<ue-metrics + title="Sales Overview" + :items='@json($metrics)' + endpoint="{{ route('metrics.refresh') }}" +/> +``` + +--- + +## `ue-metric-groups` + +Renders multiple `ue-metrics` groups in a responsive `v-row / v-col` grid. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `title` | `String` | `''` | Card title wrapping all groups | +| `items` | `Array` | required | Array of `ue-metrics` prop objects (each can include a `col` key for responsive overrides) | +| `defaultCol` | `Object` | `{cols: 12}` | Default `v-col` binding applied to every group column | +| `metricsBgHeaderColor` | `String` | `null` | Override `bgHeaderColor` for all groups | +| `metricsNoInline` | `Boolean` | `null` | Override `noInline` for all groups | +| `metricColor` | `String` | `null` | Override `color` for all individual metrics | +| `metricCardColor` | `String` | `null` | Override `cardColor` for all individual metrics | +| `metricLabelColor` | `String` | `null` | Override `labelColor` for all individual metrics | + +### Example + +```php +@php + $groups = [ + [ + 'title' => 'Sales', + 'items' => [ + ['value' => '320', 'label' => 'Orders'], + ['value' => '$14,200', 'label' => 'Revenue'], + ], + 'col' => ['cols' => 12, 'md' => 6], + ], + [ + 'title' => 'Support', + 'items' => [ + ['value' => '12', 'label' => 'Open Tickets'], + ['value' => '98%', 'label' => 'Resolution Rate'], + ], + 'col' => ['cols' => 12, 'md' => 6], + ], + ]; +@endphp + +<ue-metric-groups title="Dashboard" :items='@json($groups)' /> +``` diff --git a/docs/src/pages/guide/components/modal-media.md b/docs/src/pages/guide/components/modal-media.md new file mode 100644 index 000000000..f0b7ea3af --- /dev/null +++ b/docs/src/pages/guide/components/modal-media.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 46 +sidebarTitle: Modal Media +--- +# Modal Media (Media Library) + +`ue-modal-media` opens a fullscreen media library dialog. It combines `ue-filter`, `ue-dropdown-filter`, `ue-uploader`, and a media grid, allowing users to search, filter, upload, and select media files. + +## Usage + +```html +<ue-modal-media :type="mediaType" @insert="handleInsert"> + <template #activator="{ props }"> + <v-btn v-bind="props">Open Media Library</v-btn> + </template> +</ue-modal-media> +``` + +## Key Props + +| Prop | Type | Description | +|------|------|-------------| +| `type` | `Object\|String` | Media type configuration or type key. Controls which media types are shown and the upload endpoint | +| `types` | `Array` | Multiple media type tabs — renders type-filter chips | +| `authorized` | `Boolean` | Whether the current user can delete/modify media | +| `connector` | `Boolean` | Whether to show the "Insert" button (used when selecting for a form field) | +| `extraMetadatas` | `Array` | Extra metadata fields shown in the media sidebar | +| `translatableMetadatas` | `Array` | Metadata fields with per-locale values | +| `filterSchema` | `Object\|Array` | `ue-dropdown-filter` schema for advanced filtering | + +## Events + +| Event | Description | +|-------|-------------| +| `insert` | Emitted with the selected media items when the user confirms their selection | + +## Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `activator` | `{ props }` | Element that opens the media library modal | + +## Behaviour + +- Fetches media from the configured endpoint using `ue-filter` search and `ue-dropdown-filter` advanced filters. +- Uploads are handled by `ue-uploader` and immediately available in the grid after completion. +- The "Insert" footer button only appears when `connector` is `true` and at least one item is selected. +- Closes automatically after Insert is clicked. diff --git a/docs/src/pages/guide/components/modal.md b/docs/src/pages/guide/components/modal.md new file mode 100644 index 000000000..309d47564 --- /dev/null +++ b/docs/src/pages/guide/components/modal.md @@ -0,0 +1,90 @@ +--- +sidebarPos: 21 +sidebarTitle: Modal +--- +# Modal + +`ue-modal` is the primary dialog component. It wraps Vuetify's `v-dialog` and provides a standardised card layout with a title, description body, and confirm/cancel action buttons — all customisable through props and slots. + +## Usage + +```html +<!-- v-model controlled --> +<ue-modal v-model="showDialog" title="Delete Record" description="Are you sure?"> +</ue-modal> + +<!-- ref controlled (imperative API) --> +<ue-modal ref="confirmModal" title="Confirm"> +</ue-modal> +<!-- open programmatically --> +<script> + this.$refs.confirmModal.open() +</script> +``` + +## Common Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Boolean` | `false` | Controls dialog open state with `v-model` | +| `title` | `String` | — | Dialog title | +| `description` | `String` | — | Body text (supports HTML via `v-html`) | +| `widthType` | `String` | `'sm'` | Preset width: `'xs'`, `'sm'`, `'md'`, `'lg'`, `'xl'`, `'full'` | +| `persistent` | `Boolean` | `false` | Prevent closing by clicking outside | +| `confirmText` | `String` | i18n `fields.confirm` | Label for the confirm button | +| `cancelText` | `String` | i18n `fields.cancel` | Label for the cancel button | +| `noCancelButton` | `Boolean` | `false` | Hide the cancel button | +| `noConfirmButton` | `Boolean` | `false` | Hide the confirm button | +| `noActions` | `Boolean` | `false` | Remove the entire actions row | +| `hasCloseButton` | `Boolean` | `false` | Show an × icon in the title bar | +| `hasFullscreenButton` | `Boolean` | `false` | Show a fullscreen toggle in the title bar | +| `hasTitleDivider` | `Boolean` | `false` | Add a divider below the title | +| `confirmCallback` | `Function` | — | Async function called on confirm — return `false` to keep modal open | +| `rejectCallback` | `Function` | — | Async function called on cancel | +| `titleJustify` | `String` | `'start'` | Title alignment (`start`, `center`, `end`) | + +## Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `default` | `{close, confirm, open, toggleFullscreen, isFullActive}` | Full custom content — replaces the default card entirely | +| `body` | same scope | Replaces the default card body while keeping the dialog wrapper | +| `body.description` | `{description}` | Replaces the description text area | +| `body.options` | `{description}` | Replaces the action buttons row | +| `systembar` | — | Injected above the card when `hasSystembar` is active | + +## Events + +| Event | Description | +|-------|-------------| +| `update:modelValue` | Emitted on open/close | +| `opened` | Emitted when the dialog becomes visible | +| `confirm` | Emitted after confirm resolves | +| `cancel` | Emitted after cancel resolves | + +## Imperative API + +When used with a template `ref`, the modal exposes these methods: + +```js +this.$refs.myModal.open() // open the dialog +this.$refs.myModal.close() // close the dialog +this.$refs.myModal.toggle() // toggle open state +this.$refs.myModal.confirm() // trigger confirm flow programmatically +``` + +## Example — Confirm Delete + +```html +<ue-modal + ref="deleteModal" + title="Delete Item" + description="This action cannot be undone." + width-type="sm" + has-close-button + :confirm-callback="handleDelete" +> +</ue-modal> + +<v-btn color="error" @click="$refs.deleteModal.open()">Delete</v-btn> +``` diff --git a/docs/src/pages/guide/components/navigation-group.md b/docs/src/pages/guide/components/navigation-group.md new file mode 100644 index 000000000..5f8e84747 --- /dev/null +++ b/docs/src/pages/guide/components/navigation-group.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 28 +sidebarTitle: Navigation Group +--- +# Navigation Group + +`ue-navigation-group` renders a recursive sidebar navigation list. It supports nested subgroups, badge indicators, flyout menus, and Inertia.js links. + +## Usage + +```html +<ue-navigation-group :items="navItems" /> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `items` | `Array` | required | Array of navigation item objects (see Item Shape below) | +| `level` | `Number` | `0` | Current nesting depth — set automatically by recursive children | +| `hideIcons` | `Boolean` | `false` | Suppress prepend icons on all items | +| `showTooltip` | `Boolean` | `false` | Show item name in a tooltip — useful in rail/collapsed sidebar mode | +| `profileMenu` | `Boolean` | `false` | Render items as compact menu-route entries (used inside the profile popover) | + +## Item Shape + +Each item in the `items` array is a plain object. The component determines how to render it based on which keys are present: + +| Key | Type | Description | +|-----|------|-------------| +| `name` | `String` | Display label | +| `icon` | `String` | MDI icon name (e.g. `mdi-home`) | +| `route` / `href` | `String` | Navigation target — renders as a link or Inertia link | +| `items` | `Array` | Child items — makes this item a collapsible subgroup | +| `menuItems` | `Object` | Child items rendered as a flyout `v-menu` | +| `attr` | any | Marks the item as an event-trigger rather than a link | +| `badge` | `Number\|String` | Badge content displayed on the icon (capped at `9+`) | +| `badgeProps` | `Object` | Additional props forwarded to the `v-badge` | +| `is_active` | `Boolean\|Number` | Pre-selects this item as active and auto-opens any parent group | +| `blank` | `Boolean` | If `true`, the link opens in a new tab | +| `is_modularity_route` | `Boolean` | Marks the link as an in-app Inertia route | + +## Example + +```js +const navItems = [ + { name: 'Dashboard', icon: 'mdi-view-dashboard', route: '/dashboard', is_modularity_route: true }, + { + name: 'Settings', + icon: 'mdi-cog', + items: [ + { name: 'Profile', icon: 'mdi-account', route: '/settings/profile' }, + { name: 'Security', icon: 'mdi-lock', route: '/settings/security' }, + ], + }, + { name: 'Notifications', icon: 'mdi-bell', route: '/notifications', badge: 5 }, +] +``` + +::: tip +`ue-navigation-group` is used internally by `ue-sidebar`. You typically do not instantiate it directly — pass `items` to `ue-main` or `ue-sidebar` instead. +::: diff --git a/docs/src/pages/guide/components/others/assignee-details.md b/docs/src/pages/guide/components/others/assignee-details.md new file mode 100644 index 000000000..671fa5a7a --- /dev/null +++ b/docs/src/pages/guide/components/others/assignee-details.md @@ -0,0 +1,61 @@ +--- +sidebarPos: 2 +sidebarTitle: Assignee Details +--- + +# AssigneeDetails + +`AssigneeDetails` renders a `v-menu` popup that displays the full details of an assignment. The activator is a `v-list` item showing the assignee's name and avatar. Clicking it opens a card with the task summary, description, preliminary documents, submitted attachments, and (when the viewer is the assignee) a file upload area and action buttons. + +## Usage + +```html +<assignee-details + :assignment="assignment" + :formatted-assignment="formattedAssignment" + :is-assignee="currentUser.id === assignment.assignee_id" + :is-authorized="currentUser.canManageAssignments" + :filepond="filepond" + v-model:attachments="attachments" + v-model:attachments-loading="attachmentsLoading" + @click:complete="completeAssignment" + @click:save="saveAttachments" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `assignment` | `Object` | `{}` | Raw assignment data. Uses `description`, `preliminaries`, `attachments`, and `status`. | +| `formattedAssignment` | `Object` | `{}` | Pre-formatted display object. Used for the activator list and the card header. Must include `prependAvatar`, `assigneeName`, `title`, and `subDescription`. | +| `isAssignee` | `Boolean` | `false` | When `true`, shows the file upload field and the Complete/Save action buttons. | +| `isAuthorized` | `Boolean` | `false` | When `true`, shows the assignee activator list and (when not the assignee) the submitted attachments list. | +| `filepond` | `Object` | `null` | Filepond config forwarded to `v-input-filepond`. The `type` key is stripped before forwarding. Shown only when `isAssignee` is `true`. | +| `attachments` | `Array` | `[]` | Two-way bound (`v-model:attachments`) list of uploaded file objects. | +| `attachmentsLoading` | `Boolean` | `false` | Two-way bound (`v-model:attachments-loading`). Set to `true` while files are uploading. | +| `loading` | `Boolean` | `false` | Loading state applied to the Save button. | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `update:attachments` | `Array` | Emitted when the attachments model changes | +| `update:attachmentsLoading` | `Boolean` | Emitted when file loading state changes | +| `click:complete` | — | Emitted when the **Complete** button is clicked | +| `click:save` | `{ attachments: Array }` | Emitted when the **Save** button is clicked (only when `attachments.length > 0`) | + +## Card sections + +| Section | Visibility | Content | +|---|---|---| +| Task summary header | Always | `formattedAssignment.title` + `subDescription` via `v-list` | +| Description | Always | `assignment.description` with `mdi-information-outline` icon | +| Preliminary documents | When `assignment.preliminaries.length > 0` | File preview via `ue-filepond-preview` | +| Submitted attachments | `isAuthorized && !isAssignee && attachments.length > 0` | File preview via `ue-filepond-preview` | +| File upload | `isAssignee` | `v-input-filepond` bound to `attachments`; disabled when `assignment.status !== 'pending'` | +| Action buttons | `isAssignee` | **Complete** (disabled if not pending) and **Save** (disabled if no attachments or not pending) | + +## Status gating + +Both the file upload and the action buttons are disabled when `assignment.status !== 'pending'`. This prevents modifications to completed or cancelled assignments. diff --git a/docs/src/pages/guide/components/others/assignment-modal.md b/docs/src/pages/guide/components/others/assignment-modal.md new file mode 100644 index 000000000..ffc27a850 --- /dev/null +++ b/docs/src/pages/guide/components/others/assignment-modal.md @@ -0,0 +1,74 @@ +--- +sidebarPos: 1 +sidebarTitle: Assignment Modal +--- + +# AssignmentModal + +`AssignmentModal` is a `ue-modal` dialog for creating or editing a task assignment. It presents a validated form with an assignee selector, a due-date picker, a description textarea, and an optional preliminary-documents file upload. + +## Usage + +```html +<assignment-modal + v-model="showModal" + v-model:form="formData" + :users="userList" + :loading="saving" + :filepond="filepond" + @submit="saveAssignment" +/> +``` + +```js +const formData = ref({ + assignee_id: null, + due_at: null, + description: null, + preliminaries: [], +}) +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `modelValue` | `Object` | `{}` | Controls modal visibility (`v-model`). | +| `form` | `Object` | `{ assignee_id, due_at, description, preliminaries: [] }` | The form model (`v-model:form`). | +| `users` | `Array` | `[]` | User list for the assignee `v-select`. Each item must have `id` and `name`. | +| `loading` | `Boolean` | `false` | Shows loading state on the submit button. | +| `disabled` | `Boolean` | `false` | Disables the submit button. | +| `variant` | `String` | `'outlined'` | Vuetify input variant applied to all fields. | +| `filepond` | `Object` | `{}` | Schema-style config forwarded to `v-input-filepond` for the preliminary documents field. | +| `minDueDays` | `Number` | `0` | Minimum number of days from today allowed for the due date. Enforces a `futureDateRule`. | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `update:modelValue` | `Boolean` | Modal open/close state change | +| `update:form` | `Object` | Updated form model | +| `submit` | — | Emitted when the form passes validation and the **Assign** button is clicked | + +## Exposed methods + +| Method | Returns | Description | +|---|---|---| +| `validateForm()` | `Promise<{ valid: Boolean }>` | Programmatically validates all fields. Call this before submitting externally. | + +## Form fields + +| Field | Input type | Validation rules | +|---|---|---| +| Assignee | `v-select` (`item-value: id`, `item-title: name`) | Required | +| Due Date | `v-input-date` | Required, valid date, minimum `minDueDays` days in the future | +| Description | `v-textarea` | Required, minimum 10 characters | +| Preliminary Documents | `v-input-filepond` | Optional; `filepond` prop controls server endpoints and accepted file types | + +## Validation + +Validation is triggered on `submit` (lazy). The due-date field also validates on `blur`. The form ref is exposed as `validateForm()` for external callers. + +## Preliminary documents badge + +The preliminary-documents filepond label is wrapped in a `v-badge` with a `mdi-creation` icon to visually indicate it is an AI-assisted or special field. diff --git a/docs/src/pages/guide/components/others/chat-message.md b/docs/src/pages/guide/components/others/chat-message.md new file mode 100644 index 000000000..49db7fd8a --- /dev/null +++ b/docs/src/pages/guide/components/others/chat-message.md @@ -0,0 +1,89 @@ +--- +sidebarPos: 3 +sidebarTitle: Chat Message +--- + +# ChatMessage + +`ChatMessage` renders a single chat message bubble. It supports outgoing/incoming layout, read/unread blur state, content truncation with expand/collapse, attachment previews, star and pin actions, and relative timestamp display. + +## Usage + +```html +<chat-message + v-model="message" + update-endpoint="/api/messages/:id" + :reverse="message.user_id === currentUser.id" + :content-truncate-length="300" + no-pinning +/> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `modelValue` | `Object` | required | The message object. See [Message shape](#message-shape). | +| `updateEndpoint` | `String` | required | URL template for PATCH/PUT requests. `:id` is replaced with `message.id`. | +| `reverse` | `Boolean` | `false` | When `true`, renders the bubble on the right side (outgoing message style). | +| `avatarSize` | `Number` | `40` | Avatar diameter in px on desktop (`smAndUp`). | +| `mobileAvatarSize` | `Number` | `20` | Avatar diameter in px on mobile. | +| `contentTruncateLength` | `Number` | `50` | Character threshold above which a **Show more / Show less** toggle is displayed. | +| `noStarring` | `Boolean` | `false` | Hides the star icon. | +| `noPinning` | `Boolean` | `false` | Hides the pin icon. | + +## Message shape + +```js +{ + id: 1, + content: 'Hello world', + created_at: '2025-04-17T10:00:00Z', + is_read: false, + is_starred: false, + is_pinned: false, + attachments: [], // array of file objects (forwarded to ue-filepond-preview) + user_profile: { + name: 'Jane Doe', + avatar_url: 'https://...', + }, +} +``` + +## Read / unread state + +A message is considered **unread** when `message.is_read === false` **and** `reverse === false` (i.e. it is an incoming, not yet read message). + +Unread messages: +- Have `.message-content--unread` — content is blurred (`filter: blur(2px)`) until the user hovers. +- On hover, a 1-second timer starts. If the user stays, `markAsRead()` fires a `PUT` to `updateEndpoint` with `{ is_read: true }` and the blur clears. + +## Timestamp display + +| Time since sent | Format | +|---|---| +| < 48 hours | Relative (`moment.fromNow()`) | +| 2 – 7 days | Day name (`dddd`) | +| > 7 days | `MMM Do YY` | + +On desktop (`smAndUp`) the timestamp is displayed as absolute-positioned text in the bottom corner of the bubble. On mobile it is accessible via a `v-tooltip` on the avatar. + +## Star & pin actions + +Clicking the star or pin icon immediately calls `PUT updateEndpoint` with `{ is_starred: Boolean }` or `{ is_pinned: Boolean }`. On success the local model is updated optimistically. + +## Content truncation + +When `message.content.length > contentTruncateLength`: +- Only the first `contentTruncateLength` characters are shown, followed by `...` +- A **Show more** / **Show less** toggle button expands/collapses the full content in a `v-expand-transition` + +## Attachments + +When `message.attachments.length > 0`, a labelled `ue-filepond-preview` list is rendered below the message body. + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `update:modelValue` | `Object` | Emitted after a successful `is_starred`, `is_pinned`, or `is_read` update | diff --git a/docs/src/pages/guide/components/others/currency-number.md b/docs/src/pages/guide/components/others/currency-number.md new file mode 100644 index 000000000..2834e7d87 --- /dev/null +++ b/docs/src/pages/guide/components/others/currency-number.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 4 +sidebarTitle: Currency Number +--- + +# CurrencyNumber (`ue-currency-number`) + +`CurrencyNumber` is a `v-text-field` wrapper that formats a numeric model value as a currency string. Formatting logic is provided by the `useCurrencyNumber` composable. + +## Usage + +```html +<ue-currency-number + v-model="price" + label="Price" + :error-messages="errors.price" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `modelValue` | `Number` | — | The raw numeric value to display and edit. | +| `errorMessages` | `Array` | `[]` | Validation error messages forwarded to the underlying `v-text-field`. | + +## Attribute forwarding + +Only `label` and `error` are forwarded from `$attrs` to the `v-text-field` via `$bindAttributes($lodash.pick($attrs, ['label', 'error']))`. All other attributes (e.g. `density`, `variant`) are not forwarded — set them through the model or composable configuration. + +## Slots + +All slots defined on the parent are transparently forwarded to the inner `v-text-field`: + +```html +<ue-currency-number v-model="price" label="Amount"> + <template #append-inner> + <span>USD</span> + </template> +</ue-currency-number> +``` + +## Formatting + +The display value (`formattedValue`) is a computed ref returned by `useCurrencyNumber`. It converts the numeric `modelValue` to a locale-formatted currency string on read, and parses the raw input back to a number on write. The exact locale and currency symbol are configured inside `useCurrencyNumber`. + +## Emits + +`ue-currency-number` itself does not define custom emits — the `v-text-field` handles `update:modelValue` internally through the composable binding. diff --git a/docs/src/pages/guide/components/others/custom-form-base.md b/docs/src/pages/guide/components/others/custom-form-base.md new file mode 100644 index 000000000..a3c529065 --- /dev/null +++ b/docs/src/pages/guide/components/others/custom-form-base.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 8 +sidebarTitle: Custom Form Base (legacy) +--- + +# CustomFormBase (`v-custom-form-base`) <Badge type="warning" text="legacy" /> + +`CustomFormBase` is the original self-contained schema-driven form engine. It implements the same `VFormBase` API as [`FormBase`](./form-base) but with all rendering logic and helper methods inline in the component rather than split into a composable and a sub-component. + +> [!WARNING] +> `CustomFormBase` is kept for backwards compatibility. For new work use [`FormBase`](./form-base) (`v-form-base`), which is the refactored version with the same API. + +## API + +`CustomFormBase` accepts exactly the same props, emits, schema syntax, slot naming convention, and supported field types as `FormBase`. Refer to the [FormBase documentation](./form-base) for the complete reference. + +Key differences from `FormBase`: + +| Aspect | FormBase | CustomFormBase | +|---|---|---| +| Logic location | `useFormBaseLogic` composable | Inline in the component's `methods` and `computed` | +| Field rendering | Delegated to `FormBaseField` | Inline `v-if/v-else-if` chain in the template | +| Composition API | `setup` returns `ctx` from composable | Options API with `getCurrentInstance` in `setup` | +| Nested self-reference | `v-form-base` | `v-custom-form-base` (references itself recursively) | +| Schema rebuild trigger | `watch` on `modelValue` keys via `__dot` + `onBeforeMount` | `watch` on `modelValue` keys via `__dot` in `created` | + +## When to use + +Use `CustomFormBase` only when: +- You are maintaining existing code that already imports `v-custom-form-base`. +- You need to avoid a refactor risk in a critical path. + +In all other cases, use `FormBase`. diff --git a/docs/src/pages/guide/components/others/datatable.md b/docs/src/pages/guide/components/others/datatable.md new file mode 100644 index 000000000..a26fedf2a --- /dev/null +++ b/docs/src/pages/guide/components/others/datatable.md @@ -0,0 +1,93 @@ +--- +sidebarPos: 5 +sidebarTitle: Datatable +--- + +# Datatable + +`Datatable` wraps Vuetify's `v-data-table-server` with a standard toolbar (title, search, status filter, create button, delete confirmation modal) and flexible row actions. It is built on top of `useTable` and `makeTableProps`. + +> [!NOTE] +> This component is an earlier-generation data table. For new work, consider `ue-table` which uses the same hook but has a more complete feature set. `Datatable` is retained for existing usages. + +## Usage + +```html +<datatable + name="User" + :columns="columns" + :items="items" + :items-length="total" + :row-actions="rowActions" + create-url="/users/create" +/> +``` + +## Props + +Props are defined by `makeTableProps()` from `@/hooks/useTable`. Key props: + +| Prop | Type | Description | +|---|---|---| +| `name` | `String` | Resource name used for translated labels (add button, list title) | +| `columns` | `Array` | Column definitions. Each entry: `{ key, title, formatter? }` | +| `items` | `Array` | Current page of row data | +| `itemsLength` | `Number` | Total number of items (for server-side pagination) | +| `rowActions` | `Array` | Action buttons per row. Each: `{ name, icon, color, label? }` | +| `rowActionsType` | `String` | `'dropdown'` renders a `v-menu`; any other value renders icon buttons | +| `createOnModal` | `Boolean` | Shows a create form inside a modal instead of navigating to `createUrl` | +| `editOnModal` | `Boolean` | Opens edit form in a modal | +| `noForm` | `Boolean` | Hides the create/edit form dialog entirely | +| `createUrl` | `String` | URL for the create link button (used when `createOnModal` is `false`) | +| `titleKey` | `String` | Item property used as the row display title | + +## Toolbar + +The toolbar is rendered inside `v-slot:top` of `v-data-table-server`: + +| Element | Description | +|---|---| +| Table title | Displays `tableTitle` (from `useTable`) via `ue-title`. Override with `#header`. | +| Search | `v-text-field` bound to `search`; appends `mdi-magnify` icon | +| Status filter | Dropdown showing `mainFilters` (count per status). Calls `filterStatus(slug)`. | +| Create button | `v-btn-success` linking to `createUrl` or opening the create modal | +| Delete modal | `ue-modal` with a confirmation question and cancel/confirm buttons | + +## Column formatters + +Set `formatter` on a column definition to apply special rendering: + +| `formatter` value | Behaviour | +|---|---| +| `'edit'` | Renders the cell value as a clickable `v-btn` that calls `editItem` | +| `'activate'` | Renders the cell value as a clickable `v-btn` that calls `activateItem` | +| `'switch'` | Renders a `v-switch` (true-value `1`, false-value `0`) that calls `itemAction(item, 'switch', value, key)` | +| Any other string | Passed to `handleFormatter(formatter, value)` which renders via `ue-recursive-stuff` | + +## Row actions + +Each action in `rowActions` is rendered per row: + +| Key | Description | +|---|---| +| `name` | Action identifier passed to `itemAction(item, action)` | +| `icon` | MDI icon string, or falls back to `$name` Vuetify alias | +| `color` | Icon / button colour | +| `label` | Tooltip text (falls back to `name`) | + +On small screens (`isSmAndDown`) or when `rowActionsType === 'dropdown'`, actions are grouped into a `v-menu` (activated by `$list` icon). Otherwise individual icon buttons with tooltips are rendered. + +## Slots + +| Slot | Binding | Description | +|---|---|---| +| `header` | `{ tableTitle }` | Replaces the toolbar title area | +| `formDialog` | — | Replaces the built-in create/edit modal (only when `createOnModal || editOnModal`) | + +## Emits + +Row actions trigger `itemAction(item, action)` from `useTable`, which typically dispatches a Vuex action or calls an API endpoint based on the action name. + +## Table options + +Pagination, sorting, and multi-sort are managed via `v-model:options` bound to `options` from `useTable`. The table is fixed-header, fixed-footer, and sticky with height `window.y - 64 - 24 - 59 - 36` to fill the viewport. diff --git a/docs/src/pages/guide/components/others/form-base-field.md b/docs/src/pages/guide/components/others/form-base-field.md new file mode 100644 index 000000000..c6c602679 --- /dev/null +++ b/docs/src/pages/guide/components/others/form-base-field.md @@ -0,0 +1,63 @@ +--- +sidebarPos: 7 +sidebarTitle: Form Base Field +--- + +# FormBaseField + +`FormBaseField` is the internal field-renderer sub-component of [`FormBase`](./form-base). It receives a single schema field descriptor (`obj`) and a `ctx` handle to the parent's `useFormBaseLogic` composable, then selects and renders the appropriate Vuetify or custom component. + +> [!NOTE] +> This is an internal component of `FormBase`. You do not use it directly. Customise output through `FormBase` slots. + +## Props + +| Prop | Type | Required | Description | +|---|---|---|---| +| `obj` | `Object` | Yes | Field descriptor `{ key, value, schema }` from the flattened combined array | +| `ctx` | `Object` | Yes | The `useFormBaseLogic` composable context returned from `FormBase.setup` | +| `index` | `Number` | Yes | Field position in the sorted array | +| `formItem` | `Object` | No | Forwarded to `preview` type fields via `ue-recursive-stuff` | + +## Rendering decision tree + +Fields are matched in priority order: + +| Condition | Rendered as | +|---|---| +| `type === 'preview'` and `schema.configuration` set | `ue-recursive-stuff` | +| `type === 'dynamic-component'` | `ue-dynamic-component-renderer` | +| `type === 'title'` | Mapped component via registry | +| `type === 'radio'` | Mapped component with `options` and inject slots | +| `isDateTimeColorTypeAndExtensionText(obj)` | `v-menu` wrapping a `v-text-field` activator + a date/time/color picker | +| `type === 'array'` | `div` loop + nested `v-form-base` per item | +| `type === 'group'` or `'wrap'` | Container component (default `v-card`) + nested `v-form-base` | +| `type === 'treeview'` | `VTreeview` (from `vuetify/labs/VTreeview`) | +| `type === 'list'` | `v-list` with optional toolbar label | +| `type === 'checkbox'` or `'switch'` | Mapped component | +| `type === 'file'` | `v-file-input` | +| `type === 'icon'` | `v-icon` | +| `type === 'slider'` | `v-slider` | +| `type === 'img'` | `v-img` | +| `type === 'btn-toggle'` | `v-btn-toggle` with option buttons | +| `type === 'btn'` | `v-btn` | +| `schema.translated` is set | `v-input-locale` | +| `schema.mask` is set | Mapped component with `v-mask` directive | +| Default | Mapped component via registry | + +## Type mapping + +`ctx.mapTypeToComponent(type)` resolves a schema type string to a Vue component using the global input registry. Custom types registered via `registerInputType` are also resolved here. + +## Group / Wrap containers + +For `group` and `wrap` types, the wrapping component is resolved via `ctx.checkInternGroupType(obj)`: +- Uses `obj.schema.typeInt` if set. +- Falls back to `v-card`. +- Accepts any `v-*` or `ue-*` prefixed component name. + +Optional `title` and `subtitle` strings are rendered above the nested form using `ue-title`. + +## Slot passthrough + +`FormBaseField` passes all of `$slots` into nested `v-form-base` instances (for `array` and `group`/`wrap` types), and into each rendered component via `getInjectedScopedSlots` / `getKeyInjectSlot` for inject slots. This is how parent `FormBase` slots reach deeply nested fields. diff --git a/docs/src/pages/guide/components/others/form-base.md b/docs/src/pages/guide/components/others/form-base.md new file mode 100644 index 000000000..55ff13d2d --- /dev/null +++ b/docs/src/pages/guide/components/others/form-base.md @@ -0,0 +1,183 @@ +--- +sidebarPos: 6 +sidebarTitle: Form Base +--- + +# FormBase (`v-form-base`) + +`FormBase` is the refactored schema-driven form engine. It renders a `v-row` of `v-col` items, one per schema key, and delegates all field rendering to [`FormBaseField`](./form-base-field). Business logic lives in the `useFormBaseLogic` composable. + +This component shares its API with [`CustomFormBase`](./custom-form-base) — `CustomFormBase` is the original self-contained implementation and is kept for backwards compatibility. Use `FormBase` for all new work. + +## Usage + +```html +<v-form-base + id="my-form" + v-model="model" + v-model:schema="schema" + :col="{ cols: 12, md: 6 }" +/> +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `id` | `String` | `'form-base'` | HTML id and slot namespace prefix | +| `rootId` | `String` | `'form-base'` | Root ID for nested instances | +| `modelValue` | `Object\|Array` | `null` | Form data model (`v-model`) | +| `model` | `Object\|Array` | — | Alias for `modelValue` (legacy support) | +| `schema` | `Object\|Array` | `{}` | Schema definition (`v-model:schema`) | +| `formItem` | `Object` | `{}` | Supplementary data injected into `preview` type fields via `ue-recursive-stuff` | +| `row` | `Object` | — | Vuetify `v-row` props (default: `{ noGutters: false }`) | +| `rowGroup` | `Object` | — | `v-row` props for nested `group`/`array` types | +| `col` | `Object\|Number\|String` | — | Default `v-col` props (overrideable per field via `schema.col`) | +| `colGroup` | `Object\|Number\|String` | — | Default `v-col` props for nested types | +| `flex` | `Object\|Number\|String` | — | Deprecated alias for `col` | +| `noAutoGenerateSchema` | `Boolean` | `false` | Disables automatic schema generation from model keys | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `update:modelValue` | `Object\|Array` | Emitted on any field change | +| `update:schema` | `Object\|Array` | Emitted when schema is mutated internally | +| `input` | event object | Low-level input event with full context | +| `update` | event object | Alias; deprecated | +| `resize` | event object | Window resize event | +| `blur` | event object | Field blur event | +| `click` | event object | Field or icon click event | + +All emit payloads have the shape: +```js +{ + on: 'input', // event type + id: 'my-form', // form id + index: null, // array index (nested forms) + key: 'user.name', // dot-notation field key + value: 'Jane', // new value + obj, // internal field descriptor + data, // full current model + schema, // full current schema + parent, // parent FormBase instance +} +``` + +## Schema syntax + +Each key in `schema` maps to a field definition: + +```js +schema = { + // Shorthand: type string only + firstName: 'text', + + // Full object + email: { + type: 'email', + label: 'Email Address', + col: { cols: 12, md: 6 }, + order: 2, + rules: [(v) => !!v || 'Required'], + }, + + // Nested group + address: { + type: 'group', + title: 'Address', + schema: { + street: { type: 'text', label: 'Street' }, + city: { type: 'text', label: 'City' }, + } + } +} +``` + +### Supported schema field keys + +| Key | Description | +|---|---| +| `type` | Field type — see [Supported types](#supported-types) | +| `label` | Field label | +| `col` | `v-col` props override for this field | +| `order` | Sort order within the row (ascending) | +| `offset` | `v-col` offset props | +| `hidden` | Hide the field (`v-show`) | +| `spacer` | Inject a `v-spacer` after this field | +| `tooltip` | Tooltip text (string shorthand) or a Vuetify tooltip props object | +| `drag` | Enable drag-and-drop on this field | +| `drop` | Function called when a value is dropped on this field | +| `toCtrl` | `({ value, obj, data, schema }) => value` — transform value going to the control | +| `fromCtrl` | `({ value, obj, data, schema }) => value` — transform value coming from the control | +| `mask` | Input mask string (uses `v-mask` directive) | +| `ext` | Native `<input type>` override (e.g. `'range'`, `'number'`) | +| `typeInt` | Internal component type override (e.g. `'month'` for date pickers) | +| `translated` | When `true`, renders via `v-input-locale` for multi-language input | +| `cascade` | Key of the dependent select to update when this select changes | +| `autofill` | Array of field keys to autofill from the selected item's data | +| `searchInput` | Enables `v-model:search-input` binding | + +### Supported types + +`text`, `email`, `password`, `number`, `textarea`, `select`, `autocomplete`, `combobox`, `checkbox`, `switch`, `radio`, `slider`, `range` (via `ext`), `date`, `time`, `color`, `file`, `img`, `icon`, `btn`, `btn-toggle`, `list`, `array`, `group`, `wrap`, `treeview`, `title`, `preview`, `dynamic-component`, plus any custom type registered via `registerInputType`. + +## Slots + +FormBase generates slot names dynamically from the form `id` and field `key`. Separator: `-`. + +### Form-level slots + +| Slot name | Description | +|---|---| +| `slot-top-{id}` | Rendered at the very top of the row | +| `slot-bottom-{id}` | Rendered at the very bottom of the row | + +### Field-level slots (replace / wrap individual fields) + +All bindings include `{ obj, index, id }`. + +| Slot name | Description | +|---|---| +| `slot-top-key-{id}-{key}` | Above the field column | +| `slot-item-key-{id}-{key}` | Replaces the field entirely | +| `slot-bottom-key-{id}-{key}` | Below the field column | +| `slot-top-type-{id}-{type}` | Above all fields of this type | +| `slot-item-type-{id}-{type}` | Replaces all fields of this type | +| `slot-bottom-type-{id}-{type}` | Below all fields of this type | + +### Inject slots (inside components, e.g. `append`, `prepend`, `thumb-label`) + +``` +slot-inject-{verb}-key-{id}-{key} +``` + +Example — custom `append-inner` on the `email` field of form `my-form`: +```html +<template #slot-inject-append-inner-key-my-form-email> + <v-icon>mdi-email</v-icon> +</template> +``` + +### Tooltip slot + +``` +tooltip (default, matches all keys) +slot-tooltip-key-{id}-{key} +``` + +### Array slots + +| Slot name | Description | +|---|---| +| `slot-top-array-{id}-{key}` | Above each array item | +| `slot-item-array-{id}-{key}` | Replaces each array item | +| `slot-bottom-array-{id}-{key}` | Below each array item | + +Array item bindings: `{ obj, id, index, idx, item }`. + +## Schema rebuilding + +`FormBase` calls `rebuildArrays(model, schema)` on `beforeMount` and whenever the top-level keys of `modelValue` change (detected via `JSON.stringify(Object.keys(__dot(value)))`). This flattens the nested model and schema into a single sorted array for rendering. + +If `schema` is empty and `noAutoGenerateSchema` is `false`, a minimal schema is auto-generated from the model's value types. diff --git a/docs/src/pages/guide/components/others/overview.md b/docs/src/pages/guide/components/others/overview.md new file mode 100644 index 000000000..05cafe65f --- /dev/null +++ b/docs/src/pages/guide/components/others/overview.md @@ -0,0 +1,28 @@ +--- +sidebarPos: 50 +sidebarTitle: Overview +sidebarGroupTitle: Others +--- + +# Others + +Miscellaneous components that don't fit a single category — form engine internals, assignment workflow UI, messaging, and data display utilities. + +## Components + +| Component | Tag | Description | +|---|---|---| +| [`AssignmentModal`](./assignment-modal) | — | Modal form for creating or editing a task assignment | +| [`AssigneeDetails`](./assignee-details) | — | Popover showing assignment details with file upload and action buttons | +| [`ChatMessage`](./chat-message) | — | Single chat message bubble with read/unread state and attachments | +| [`CurrencyNumber`](./currency-number) | `ue-currency-number` | Currency-formatted text field input | +| [`Datatable`](./datatable) | — | Server-side data table with toolbar, filters, and row actions | +| [`FormBase`](./form-base) | `v-form-base` | Refactored schema-driven form engine (current version) | +| [`FormBaseField`](./form-base-field) | — | Internal field renderer for `FormBase` | +| [`CustomFormBase`](./custom-form-base) | `v-custom-form-base` | Original schema-driven form engine (legacy, self-contained) | + +## FormBase vs CustomFormBase + +`FormBase` and `CustomFormBase` expose the same `v-form-base` API and schema syntax. `FormBase` is the refactored version: its rendering logic is extracted into `useFormBaseLogic` (composable) and `FormBaseField` (sub-component), making it easier to maintain and extend. `CustomFormBase` is the original monolithic implementation and is kept for backwards compatibility. + +Use `FormBase` (`v-form-base`) for all new work. diff --git a/docs/src/pages/guide/components/overview.md b/docs/src/pages/guide/components/overview.md index 53c0f9614..4524349fd 100644 --- a/docs/src/pages/guide/components/overview.md +++ b/docs/src/pages/guide/components/overview.md @@ -1,44 +1,139 @@ --- sidebarPos: 0 -sidebarTitle: Components Overview +sidebarTitle: Overview --- # Components Overview -Modularity's Vue components are organized by purpose. Most are in `vue/src/js/components/`. +Modularity ships with 50+ Vue components covering forms, tables, modals, navigation, layouts, and more. All components live in `vue/src/js/components/`. -## Organization +See [Components Overview](./overview) for how components are organized in the source tree, the input registry, and labs/experimental conventions. -| Location | Purpose | -|----------|---------| -| `components/` | Root components (Form, Auth, Table, etc.) | -| `components/layouts/` | Layout components (Main, Sidebar, Home) | -| `components/inputs/` | Form input components | -| `components/modals/` | Modal components | -| `components/table/` | Table-related components | -| `components/data_iterators/` | RichRowIterator, RichCardIterator | -| `components/customs/` | App-specific overrides (UeCustom*) | -| `components/labs/` | **Experimental** — not guaranteed stable | +## Forms & Inputs -## Labs Components +| Component | Purpose | +|-----------|---------| +| [Form](./form) | Root form wrapper; wires fields to schema and submit flow | +| [Form Actions](./form-actions) | Submit / cancel / custom action buttons | +| [Form Summary Item](./form-summary-item) | Read-only summary row for form review | +| [Stepper Form](./stepper-form) | Legacy stepper form (see `stepper/` for current) | -Components in `labs/` are experimental. They may change or be removed. Use with caution. +Form inputs (30+ `input-*` components) are documented under [Form Inputs](../form-inputs/overview). -Current labs: InputDate, InputColor, InputTreeview, etc. +## Tables & Data -To enable labs in build, set `VUE_ENABLE_LABS=true` (if supported by your build config). +| Component | Purpose | +|-----------|---------| +| [Data Tables](./data-tables) | Primary table component — filters, sorting, pagination, actions | +| [Data Iterators](./data-iterators) | Row/card iterators (RichRow, RichCard) | +| [Table Binder](./table-binder) | Binds a repository response to a table | +| [Table Internals](./table-internals) | Lower-level table primitives | -## Input Registry +## Modals -Custom input types are registered via `@/components/inputs/registry`: +| Component | Purpose | +|-----------|---------| +| [Modal](./modal) | Base modal wrapper | +| [Dynamic Modal](./dynamic-modal) | Modal driven by route/payload | +| [Modal Media](./modal-media) | Media-picker modal | +| [Logout Modal](./logout-modal) | Session / logout confirmation | -```js -import { registerInputType } from '@/components/inputs/registry' -registerInputType('my-input', 'VMyInput') -``` +## Navigation & Structure -See [Hydrates](/system-reference/hydrates) for the backend schema flow. +| Component | Purpose | +|-----------|---------| +| [Tabs](./tabs) | Standard tabs | +| [Tab Groups](./tab-groups) | Grouped tabs with shared state | +| [Navigation Group](./navigation-group) | Sidebar navigation group | +| [Collapsible](./collapsible) | Collapsible wrapper | +| [Expansion](./expansion) | Expansion panel | -## Composition API +## Display -New components should use Vue 3 Composition API. Existing Options API components are being migrated incrementally. +| Component | Purpose | +|-----------|---------| +| [Configurable Card](./configurable-card) | Card with slot-based sections | +| [Metric](./metric) | Stat/metric tile | +| [Property List](./property-list) | Key/value list for record detail | +| [List Section](./list-section) | Titled list block | +| [Text Display](./text-display) | Truncation, copy-on-click, formatting | +| [Title](./title) | Page/section title with actions | +| [Markdown Render](./markdown-render) | Markdown → HTML renderer | +| [Currency Number](./currency-number) | Formatted currency display | +| [SVG Icon](./svg-icon) | Icon renderer | + +## Feedback + +| Component | Purpose | +|-----------|---------| +| [Alert](./alert) | Alert banner (info, warning, error, success) | +| [Error Card](./error-card) | Error detail card | +| [Success](./success) | Success confirmation block | + +## Files & Media + +| Component | Purpose | +|-----------|---------| +| [File Item](./file-item) | File row with actions | +| [Filepond Preview](./filepond-preview) | Filepond attachment preview | +| [Uploader](./uploader) | File upload widget | + +## Filters & Search + +| Component | Purpose | +|-----------|---------| +| [Filter](./filter) | Filter bar | +| [Dropdown Filter](./dropdown-filter) | Dropdown-based filter | + +## Actions & Utilities + +| Component | Purpose | +|-----------|---------| +| [Btn](./btn) | Button wrapper with consistent styling | +| [Copy Text](./copy-text) | Copy-to-clipboard text | +| [Print Request](./print-request) | Print-ready request view | +| [Well Print](./well-print) | Print wrapper block | +| [Dynamic Component Renderer](./dynamic-component-renderer) | Renders a component by name | +| [Recursive Data Viewer](./recursive-data-viewer) | Tree/object viewer | +| [Recursive Stuff](./recursive-stuff) | Recursive utility rendering | + +## Content & Messaging + +| Component | Purpose | +|-----------|---------| +| [Blocks](./blocks) | Block system renderer | +| [Board Information Plus](./board-information-plus) | Info board card | +| [Chat Message](./chat-message) | Chat message row | +| [Assignee Details](./assignee-details) | Assignee info block | + +## Auth + +| Component | Purpose | +|-----------|---------| +| [Auth](./auth) | Auth shell / guard wrapper | +| [Impersonate Toolbar](./impersonate-toolbar) | Impersonation banner | + +## Payments + +| Component | Purpose | +|-----------|---------| +| [Revolut Checkout](./revolut-checkout) | Revolut payment checkout integration | + +--- + +## Subcategories + +| Subcategory | Description | +|-------------|-------------| +| [Layouts](./layouts/overview) | App layout components — Main, Sidebar, Home, Footer | +| [Stepper](./stepper/overview) | Stepper form components — Header, Content, Summary, Preview | +| [Labs](./labs/overview) | Experimental components — may change or be removed | +| [Others](./others/overview) | Assignment modal, custom form bases, datatable legacy | + +--- + +## Related + +- [Form Inputs](../form-inputs/overview) — `input-*` components (date, file, price, repeater, etc.) +- [Module Features](../module-features/overview) — traits that generate UI (files, payment, chat, etc.) +- [Hydrates](/system-reference/hydrates) — backend → frontend schema transformation diff --git a/docs/src/pages/guide/components/print-request.md b/docs/src/pages/guide/components/print-request.md new file mode 100644 index 000000000..2231c94c4 --- /dev/null +++ b/docs/src/pages/guide/components/print-request.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 32 +sidebarTitle: Print Request +--- +# Print Request + +`ue-print-request` fetches data from an endpoint on mount and displays the result using `ue-text-display`. It re-fetches automatically whenever `payload` changes. + +## Usage + +```html +<ue-print-request + :endpoint="/api/orders/summary" + :payload="{ order_id: orderId }" + :print-keys="{ text: 'total', subText: 'currency' }" +/> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `endpoint` | `String` | yes | POST URL to fetch data from | +| `payload` | `Object` | yes | Request body sent with the POST request; watched deeply for re-fetch | +| `printKeys` | `Array\|Object` | yes | Describes how to extract display values from the response (see below) | +| `loadingText` | `String` | `'Loading...'` | Text shown below the spinner while fetching | + +## `printKeys` format + +`printKeys` can be: + +- **A single object** `{ text: 'fieldName', subText: 'fieldName' }` — used when the response is a single object. +- **An array of strings** `['field1', 'field2']` — each item maps to a display row when the response is an array. +- **An array of objects** `[{ text: 'field', subText: 'field' }, ...]` — explicit per-row mapping for array responses. + +## Behaviour + +- Renders a spinner with `loadingText` while the request is in-flight. +- For array responses, each element is rendered as a separate `ue-text-display`. +- For single-object responses, one `ue-text-display` is rendered. +- Errors are logged to the console; the component remains in the loading state if the request fails. diff --git a/docs/src/pages/guide/components/property-list.md b/docs/src/pages/guide/components/property-list.md new file mode 100644 index 000000000..b7c48e2da --- /dev/null +++ b/docs/src/pages/guide/components/property-list.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 9 +sidebarTitle: Property List +--- +# Property List + +The `ue-property-list` component renders a key–value list in caption-sized text. It accepts either an array of `[key, value]` pairs or a plain object. + +## Usage + +```html +<!-- From an object --> +<ue-property-list :data="{ Name: 'Alice', Role: 'Admin', Status: 'Active' }" /> + +<!-- From an array of pairs --> +<ue-property-list :data="[['Name', 'Alice'], ['Role', 'Admin']]" /> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `Array\|Object` | `[]` | The data to display. Objects are converted to `[key, value]` pairs internally | +| `noPadding` | `Boolean` | `false` | Remove the default vertical padding | + +## Data Formats + +**Object** — keys become labels, values become the displayed content: +```js +{ 'Created At': '2025-01-15', 'Updated At': '2025-03-02' } +``` + +**Array of pairs** — each element is `[label, ...values]`. Multiple values are joined with `, `: +```js +[ + ['Name', 'Alice'], + ['Tags', 'admin', 'editor'], // renders "admin, editor" +] +``` + +## Example — Item Detail Panel + +```php +@php + $details = [ + 'ID' => $item->id, + 'Created' => $item->created_at->format('Y-m-d'), + 'Status' => $item->status, + 'Assigned To'=> $item->assignee?->name ?? '—', + ]; +@endphp + +<ue-property-list :data='@json($details)' /> +``` diff --git a/docs/src/pages/guide/components/recursive-data-viewer.md b/docs/src/pages/guide/components/recursive-data-viewer.md new file mode 100644 index 000000000..07f3b6451 --- /dev/null +++ b/docs/src/pages/guide/components/recursive-data-viewer.md @@ -0,0 +1,34 @@ +--- +sidebarPos: 33 +sidebarTitle: Recursive Data Viewer +--- +# Recursive Data Viewer + +`ue-recursive-data-viewer` renders arbitrary JSON data — objects, arrays, and primitives — as an interactive, collapsible tree. It is the equivalent of a browser DevTools JSON inspector for use inside views. + +## Usage + +```html +<ue-recursive-data-viewer :data="responsePayload" /> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `Array\|Object\|String\|Number\|Boolean` | required | The value to render | +| `allArrayItemsClosed` | `Boolean` | `false` | Collapse all array items on initial render | +| `allArrayItemsOpen` | `Boolean` | `false` | Expand all array items on initial render | +| `allObjectsClosed` | `Boolean` | `false` | Collapse all objects on initial render | +| `allObjectsOpen` | `Boolean` | `false` | Expand all objects on initial render | +| `objectDepth` | `Number` | `0` | Current recursion depth (set automatically by child instances) | +| `arrayIndex` | `Number` | `null` | Index in a parent array (set automatically) | +| `objectTitle` | `String` | `null` | Optional label shown above the object (internal use) | + +## Behaviour + +- **Arrays** are rendered with an item count badge. Each element can be expanded or collapsed independently. +- **Objects** render as a collapsible table of key/value pairs. The first object at depth 0 is expanded by default. +- **Primitives** (string, number, boolean) are rendered as plain text inline. +- Object keys are styled in a monospace purple font, matching common JSON viewer conventions. +- The component is fully recursive — nested objects and arrays are rendered by child `ue-recursive-data-viewer` instances. diff --git a/docs/src/pages/guide/components/recursive-stuff.md b/docs/src/pages/guide/components/recursive-stuff.md new file mode 100644 index 000000000..333a943bc --- /dev/null +++ b/docs/src/pages/guide/components/recursive-stuff.md @@ -0,0 +1,70 @@ +--- +sidebarPos: 23 +sidebarTitle: Recursive Stuff +--- +# Recursive Stuff + +`ue-recursive-stuff` is the configuration-driven component renderer at the heart of Modularous dynamic UI system. It takes a `configuration` object that describes a component tree and renders it recursively. + +## Configuration Object Shape + +```js +{ + tag: 'v-chip', // any registered component or HTML tag + attributes: { // props / attrs bound to the component + color: 'primary', + size: 'small', + }, + elements: 'Hello World', // child content: string | config object | array of configs + slots: { // named slot content, each value is another configuration + prepend: { tag: 'v-icon', attributes: { icon: 'mdi-check' } } + }, + bind: ['item'], // keys from bindData to spread as attributes + directives: { // Vue directives to apply + show: '$item.active' + } +} +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `configuration` | `Object\|Array\|String` | `{}` | The component/element descriptor | +| `level` | `Number` | `0` | Current recursion depth (set automatically by recursive children) | +| `bindData` | `Object` | `{}` | Contextual data available for `$` interpolation inside attribute values | + +## Data Binding Syntax + +Attribute values starting with `$` are resolved from `bindData`: + +```js +{ tag: 'span', elements: '$item.name' } +// renders the value of bindData.item.name +``` + +Expression syntax is also supported with `{...}`: + +```js +{ tag: 'span', elements: '{ $count + 1 }' } +``` + +## Usage from PHP / Blade + +Modularous backend services generate `ue-recursive-stuff` configuration objects automatically for table formatters, form slots, and index page blocks. You can also build them manually: + +```php +$configuration = [ + 'tag' => 'v-chip', + 'attributes' => ['color' => 'success', 'size' => 'small'], + 'elements' => 'Active', +]; +``` + +```html +<ue-recursive-stuff :configuration='@json($configuration)' /> +``` + +::: tip Formatters +Table column formatters defined in `config.php` (`'formatter' => [...]`) are converted to `ue-recursive-stuff` configurations automatically by the datatable rendering pipeline. +::: diff --git a/docs/src/pages/guide/components/revolut-checkout.md b/docs/src/pages/guide/components/revolut-checkout.md new file mode 100644 index 000000000..272ee3ccd --- /dev/null +++ b/docs/src/pages/guide/components/revolut-checkout.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 39 +sidebarTitle: Revolut Checkout +--- +# Revolut Checkout + +`ue-revolut-checkout` embeds a Revolut card field and handles the full payment flow: card submission, processing overlay, success redirect, and failure handling via `ue-dynamic-modal`. + +`ue-revolut-checkout-modal` is a convenience wrapper that opens the checkout inside a `ue-modal`. + +## `ue-revolut-checkout` + +### Usage + +```html +<ue-revolut-checkout + :token="revolutToken" + env="sandbox" + :payment-id="paymentId" + :revolut-order-id="revolutOrderId" + :order-id="orderId" + complete-url="/api/payments/complete" +/> +``` + +### Props + +| Prop | Type | Description | +|------|------|-------------| +| `token` | `String` | Revolut public token for the order | +| `env` | `String` | `'sandbox'` or `'production'` | +| `paymentId` | `String` | Internal payment record ID | +| `revolutOrderId` | `String` | Revolut order ID | +| `orderId` | `String` | Internal order ID | +| `completeUrl` | `String` | Endpoint called after Revolut reports success or failure | + +### Behaviour + +1. On mount, the Revolut SDK initialises and mounts a card field inside `#embed-form`. +2. When the user clicks **Pay**, the card field is submitted to Revolut. +3. On success, a processing overlay modal is shown and `completeUrl` is called with `status=success`. +4. If the backend returns `variant: 'success'`, the user is redirected to the URL in the response. +5. On failure, a `ue-error-card` modal is opened with the error details. + +## `ue-revolut-checkout-modal` + +Wraps `ue-revolut-checkout` inside a `ue-modal`, activating it programmatically. + +```html +<ue-revolut-checkout-modal + :token="token" + env="sandbox" + :payment-id="paymentId" + :revolut-order-id="revolutOrderId" + :order-id="orderId" + complete-url="/api/payments/complete" +> + <template #activator="{ open }"> + <v-btn color="primary" @click="open">Pay Now</v-btn> + </template> +</ue-revolut-checkout-modal> +``` + +::: warning +The `@revolut/checkout` SDK is loaded as a package dependency. Ensure your Revolut integration is configured in the Modularous payment settings before using this component. +::: diff --git a/docs/src/pages/guide/components/stepper-form.md b/docs/src/pages/guide/components/stepper-form.md index 389cdeee5..77c39a3f4 100644 --- a/docs/src/pages/guide/components/stepper-form.md +++ b/docs/src/pages/guide/components/stepper-form.md @@ -1,54 +1,10 @@ --- -# sidebarPos: 3 +sidebarPos: 40 +sidebarTitle: Stepper Form (legacy) --- -# Stepper Form <Badge type="tip" text="^0.9.2" /> +# Stepper Form -The `ue-stepper-form` component adds multistaging forms within a ui structure. Each form in stepper form behaves like standard **ue-form** component. It also offers some features in addition to the form such as previewing form data. +> [!TIP] +> Full documentation has moved to the **[Stepper Form](./stepper/overview)** section, which covers all sub-components and a complete props/slots reference. -## Usage -It has 'forms' prop as array, it's every element is a form consisting of fields such as title and schema. The schema field must be input schema made up of standard inputs. -``` php - @php - $forms = [ - [ - 'title' => 'Title 1', - 'id' => 'stepper-form-1', - 'previewTitle' => 'custom preview title for title of preview card', - 'schema' => $this->createFormSchema([ - [ - 'type' => 'any-type', - 'name' => 'name-1', - ... - ], - [ - 'type' => 'any-type', - 'name' => 'name-2', - ... - ] - ]) - ], - [ - 'title' => 'Title 2', - 'schema' => $this->createFormSchema([ - [ - 'type' => 'any-type', - 'name' => 'name-1', - ] - [ - 'type' => 'any-type', - 'name' => 'name-2', - ] - ]) - ], - ] - @endphp - - <ue-stepper-form :forms='@json($forms)'/> -``` - -> [!IMPORTANT] -> This component was introduced in [v0.9.2] - - -### diff --git a/docs/src/pages/guide/components/stepper/overview.md b/docs/src/pages/guide/components/stepper/overview.md new file mode 100644 index 000000000..a38212be2 --- /dev/null +++ b/docs/src/pages/guide/components/stepper/overview.md @@ -0,0 +1,220 @@ +--- +sidebarPos: 4 +sidebarTitle: Overview +sidebarGroupTitle: Stepper Form +--- + +# Stepper Form <Badge type="tip" text="^0.9.2" /> + +The `ue-stepper-form` component provides multi-step forms with a built-in summary sidebar, step navigation, validation, and a final preview step. Each step behaves like a standard `ue-form`, with all schema-driven field types supported. + +## Architecture + +The stepper is composed of five internal sub-components that work together: + +| Component | Role | +|---|---| +| [`StepperHeader`](./stepper-header) | Step indicator bar at the top | +| [`StepperContent`](./stepper-content) | Main form area (left column) | +| [`StepperSummary`](./stepper-summary) | Summary sidebar (right column) | +| [`StepperPreview`](./stepper-preview) | Final step preview with selectable cards | +| [`StepperFinalSummary`](./stepper-final-summary) | Final summary card shown in the sidebar on the last step | + +``` +┌──────────────── StepperHeader ────────────────┐ +│ Step 1 Step 2 Step 3 Preview & Summary │ +└───────────────────────────────────────────────┘ +┌────────────────────────┐ ┌────────────────────┐ +│ │ │ │ +│ StepperContent │ │ StepperSummary │ +│ (ue-form per step) │ │ (per-step or │ +│ │ │ final summary) │ +│ StepperPreview │ │ │ +│ (on final step) │ │ StepperFinalSummary│ +│ │ │ (on final step) │ +└────────────────────────┘ └────────────────────┘ +``` + +## Basic Usage + +```php +@php + $forms = [ + [ + 'title' => 'Step 1 Title', + 'id' => 'stepper-form-1', + 'previewTitle' => 'Custom preview card title', + 'schema' => $this->createFormSchema([ + ['type' => 'text', 'name' => 'first_name'], + ['type' => 'email', 'name' => 'email'], + ]), + ], + [ + 'title' => 'Step 2 Title', + 'schema' => $this->createFormSchema([ + ['type' => 'text', 'name' => 'company'], + ]), + ], + ]; +@endphp + +<ue-stepper-form + :forms="@json($forms)" + action-url="/api/submit" + redirect-url="/dashboard" +/> +``` + +> [!IMPORTANT] +> This component was introduced in [v0.9.2] + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `forms` | `Array` | `[]` | Array of form definitions. Each entry must have a `title` and `schema`. See [Form Definition](#form-definition). | +| `modelValue` | `Object` | `{}` | Initial model values, merged into each form's model on mount. | +| `actionUrl` | `String` | — | Endpoint for the final form submission (`POST` or `PUT` if `modelValue.id` is set). | +| `redirectUrl` | `String` | `null` | URL to navigate to after successful submission. | +| `currentStep` | `Number` | `1` | Step to start on. | +| `preview` | `Array` | `[]` | Initial `previewModel` values (per-step preview data). | +| `isEditing` | `Boolean` | `false` | Passed down to each `ue-form` to enable edit mode. | +| `flexBreakpoint` | `String` | `null` | Breakpoint at which the stepper switches to flex layout (`sm` \| `md` \| `lg` \| `xl` \| `xxl`). | +| `cardsNotation` | `String` | `'models.1.pressReleasePackages'` | Dot-notation path to extract summary card data. | +| `summaryNotations` | `Array\|Object` | `{}` | Notations that control what appears in the step summary sidebar. | +| `previewNotations` | `Array\|Object` | `[]` | Notations that control the formatted preview cards on the final step. | +| `finalFormTitle` | `String` | `null` | Title shown above the selectable cards on the final preview step. | +| `finalFormSubtitle` | `String` | `null` | Subtitle shown below `finalFormTitle`. | +| `finalFormFields` | `Array` | `[]` | Defines selectable fields on the final step. See [Final Form Fields](#final-form-fields). | +| `protectInitialValue` | `Boolean` | `false` | When `true`, pre-selected items from `modelValue` are read-only on the final step. | +| `validationScrollingDuration` | `Number` | `1000` | Scroll animation duration (ms) when auto-scrolling to a validation error. | +| `validationScrollingEasing` | `String` | `'easeInOutCubic'` | Easing function for validation scroll. | +| `validationScrollingOffset` | `Number` | `0` | Pixel offset applied during validation scroll. | +| `responseModalIcon` | `String` | `'mdi-check-circle-outline'` | Icon shown in the success modal. | +| `responseModalTitle` | `String` | `$t('Request Complete')` | Title shown in the success modal. | +| `responseModalMessage` | `String` | `$t('Congratulations!...')` | Body text shown in the success modal. | +| `responseModalButtonText` | `String` | `'Ok'` | Button label in the success modal. | +| `responseModalOptions` | `Object` | `{}` | Extra props forwarded to the `ue-modal` success dialog. | + +## Form Definition + +Each element of the `forms` array is a plain object: + +| Key | Type | Required | Description | +|---|---|---|---| +| `title` | `String` | Yes | Label shown in the step header and summary. | +| `schema` | `Array` | Yes | Schema produced by `createFormSchema(...)`. | +| `id` | `String` | No | HTML `id` for the step's form element. | +| `previewTitle` | `String` | No | Overrides `title` for the summary preview card header. | +| `summaryTitle` | `String` | No | Overrides `title` specifically for the sidebar summary. | +| `summarySearchHaystack` | `String` | No | `'model'` (default) or `'schema'` — where to resolve the summary title. | +| `summarySearchInput` | `String` | No | Input name used to resolve a dynamic schema-based summary title. | +| `fullWidth` | `Boolean` | No | When `true`, the form takes full width and the summary sidebar is hidden. | + +## Slots + +### `summary-form-{n}` + +Replaces the default summary card for step `n` (1-based). Receives a scoped object: + +```html +<template #summary-form-1="{ title, model, schema, previewModel, index, order, length }"> + <div>Custom summary for step 1</div> +</template> +``` + +| Binding | Description | +|---|---| +| `index` | Zero-based step index | +| `order` | One-based step number | +| `title` | Resolved preview title for this step | +| `model` | Form model for this step | +| `schema` | Form schema for this step | +| `previewModel` | Preview model for this step | +| `isPreviewModelFilled` | Function: `(index) => Boolean` | +| `length` | Total number of steps | + +### `summary.final` + +Replaces the entire final-step summary panel. Receives: + +```html +<template #summary.final="{ model, schema, previewModel, completeForm }"> + <!-- custom final summary --> +</template> +``` + +### `summary.final.body` + +Injects content into the body section of the default `StepperFinalSummary` card: + +```html +<template #summary.final.body="{ models, schemas, lastStepModel, finalFormFields, lastFormPreview }"> + <!-- line items, pricing breakdown, etc. --> +</template> +``` + +### `summary.final.total.label` / `summary.final.total` + +Override the "Total" label and value in the final summary: + +```html +<template #summary.final.total.label>Price</template> +<template #summary.final.total="{ payload }"> + {{ payload.amount_formatted }} +</template> +``` + +### `summary.final.description` + +Override the description text below the total: + +```html +<template #summary.final.description> + Prices are exclusive of VAT. +</template> +``` + +## Final Form Fields + +`finalFormFields` defines selectable items shown on the final preview step. Each entry is either a dot-notation string or a configuration object: + +```js +finalFormFields: [ + { + modelNotation: 'models.0.package_id', // dot-path into models + fieldName: 'selected_packages', // key written into the payload + endpoint: '/api/packages', // fetches available options + notation: 'packages', // key inside each API response item + afterStep: 1, // fetch after leaving step 1 + cardFields: ['name', 'description', 'tags'], + format: 'id', // 'id' or an object map + formatSourceKey: 'id', + formatUniqueKey: 'id', + } +] +``` + +| Key | Type | Description | +|---|---|---| +| `modelNotation` | `String` | Dot-path into `models` whose value provides the selected IDs. | +| `fieldName` | `String` | Key written to the final payload. Defaults to the last segment of `modelNotation`. | +| `endpoint` | `String` | API endpoint to fetch available items. Receives `?ids[]=...` for new IDs. | +| `notation` | `String` | Property inside each fetched item that contains the selectable sub-items. | +| `afterStep` | `Number` | Step number after which the fetch is triggered (on step advance). | +| `cardFields` | `Array` | Fields from the item to display in the card. Nested arrays create grouped cells. | +| `format` | `String\|Object` | `'id'` stores IDs; an object maps payload keys to item fields. | +| `formatSourceKey` | `String` | Source key on the fetched item for uniqueness checks (default `'id'`). | +| `formatUniqueKey` | `String` | Key used when matching against the stored object array (default `'id'`). | + +## Full-Width Steps + +Set `fullWidth: true` on a form definition to make that step span the full container width while hiding the summary sidebar. Navigation uses a `v-stepper-actions` bar at the bottom instead. + +```js +{ + title: 'Wide Step', + fullWidth: true, + schema: $this->createFormSchema([...]), +} +``` diff --git a/docs/src/pages/guide/components/stepper/stepper-content.md b/docs/src/pages/guide/components/stepper/stepper-content.md new file mode 100644 index 000000000..26415efa5 --- /dev/null +++ b/docs/src/pages/guide/components/stepper/stepper-content.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 2 +sidebarTitle: Stepper Content +--- + +# StepperContent + +`StepperContent` is the left-column panel that houses one `ue-form` per step and a custom preview slot on the final step. It manages local copies of models and schemas to keep updates reactive without unnecessary re-renders. + +> [!NOTE] +> This is an internal sub-component of `ue-stepper-form`. You do not use it directly. + +## Behaviour + +- Renders a `v-stepper-window-item` for each entry in `forms`, each containing a `ue-form` bound to the corresponding model and schema. +- Forms are mounted lazily: a form only renders once `activeStep > i` (i.e. the user has reached or passed that step). +- On the final step (`value = forms.length + 1`) the `preview` slot is rendered instead of a form. +- The component keeps internal `models` and `localSchemas` clones, syncing them with the parent via watchers and emitting `update:modelValue` / `update:schemas` when they change. +- The scrollable window (`#ue-stepper-content-window`) respects `maxHeight` minus `coverHeight` so full-width steps can offset for the bottom action bar. + +## Props + +| Prop | Type | Required | Default | Description | +|---|---|---|---|---| +| `modelValue` | `Array` | Yes | — | Array of per-step model objects. | +| `forms` | `Array` | Yes | — | The `forms` array from the parent stepper. | +| `schemas` | `Array` | Yes | `[]` | Array of per-step schema arrays. | +| `activeStep` | `Number` | Yes | — | The currently active 1-based step. | +| `formRefs` | `Array` | Yes | — | Array of `ref` handles, one per step, used by the parent for programmatic validation. | +| `isEditing` | `Boolean` | No | `false` | Passed to each `ue-form` to enable edit mode. | +| `maxHeight` | `String` | No | `'84vh'` | CSS value for the scrollable window's maximum height. | +| `coverHeight` | `Number` | No | `0` | Pixel height to subtract from `maxHeight` (e.g. bottom action bar height). | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `update:modelValue` | `Array` | Emitted when any step's model changes. | +| `update:schemas` | `Array` | Emitted when any step's schema changes. | +| `form-input` | `{ event, index }` | Forwarded from each `ue-form`'s `input` event; `index` is the 0-based step index. | +| `form-valid` | `{ event, index }` | Forwarded from each `ue-form`'s `update:valid` event; `event` is `true/false`. | + +## Slots + +| Slot | Description | +|---|---| +| `preview` | Rendered inside the final step's `v-stepper-window-item`. Used by `StepperForm` to inject `StepperPreview`. | diff --git a/docs/src/pages/guide/components/stepper/stepper-final-summary.md b/docs/src/pages/guide/components/stepper/stepper-final-summary.md new file mode 100644 index 000000000..9ce2f8fd9 --- /dev/null +++ b/docs/src/pages/guide/components/stepper/stepper-final-summary.md @@ -0,0 +1,94 @@ +--- +sidebarPos: 5 +sidebarTitle: Stepper Final Summary +--- + +# StepperFinalSummary + +`StepperFinalSummary` is the right-column card rendered on the final step. It provides a structured layout for a body section, a total row, a description, and a **Complete Request** button. + +> [!NOTE] +> This is an internal sub-component of `ue-stepper-form`. You do not use it directly. Customise it via the `summary.final.body`, `summary.final.total`, `summary.final.total.label`, and `summary.final.description` slots on `ue-stepper-form`. + +## Layout + +``` +┌─────────────────────────────────┐ +│ TOTAL AMOUNT │ ← fixed title (bg-primary-darken-2) +│ ───────────────────────────── │ +│ [body slot] │ ← line items, pricing breakdown, etc. +├─────────────────────────────────┤ +│ [total.label] [total] │ ← total row +│ [description] │ ← caption text +│ [ COMPLETE REQUEST ] │ ← submit button +└─────────────────────────────────┘ +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `loading` | `Boolean` | `false` | Shows a loading spinner on the button and disables it. | +| `isCompleted` | `Boolean` | `false` | Disables the button permanently after a successful submission. | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `complete` | — | Emitted when the **Complete Request** button is clicked. Wired to `completeForm()` in `StepperForm`. | + +## Slots + +All slots are exposed on the parent `ue-stepper-form` via the `summary.final.*` namespace. + +### `body` + +Content injected between the title divider and the total row. Use this for line items, pricing breakdown tables, or any custom content. + +```html +<!-- on ue-stepper-form --> +<template #summary.final.body="{ models, schemas, lastStepModel, finalFormFields, lastFormPreview }"> + <div v-for="item in lastFormPreview" :key="item.id" class="d-flex justify-space-between"> + <span>{{ item.name }}</span> + <span>{{ item.price_formatted }}</span> + </div> +</template> +``` + +| Binding | Description | +|---|---| +| `models` | All step model objects | +| `schemas` | All step schema arrays | +| `lastStepModel` | Currently selected final-step values | +| `finalFormFields` | The `finalFormFields` prop from the parent | +| `lastFormPreview` | Fetched items from the final-form endpoints | + +### `total.label` + +Overrides the "TOTAL" label text: + +```html +<template #summary.final.total.label> + Grand Total +</template> +``` + +### `total` + +Overrides the total value cell. Receives `{ payload }` (the full merged payload that will be submitted): + +```html +<template #summary.final.total="{ payload }"> + <span class="text-h5 text-white">{{ payload.amount_formatted }}</span> +</template> +``` + +### `description` + +Overrides the caption text below the total row: + +```html +<template #summary.final.description> + All prices are exclusive of VAT. Payment will be processed on confirmation. +</template> +``` diff --git a/docs/src/pages/guide/components/stepper/stepper-header.md b/docs/src/pages/guide/components/stepper/stepper-header.md new file mode 100644 index 000000000..601f5b5e8 --- /dev/null +++ b/docs/src/pages/guide/components/stepper/stepper-header.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 1 +sidebarTitle: Stepper Header +--- + +# StepperHeader + +`StepperHeader` renders the horizontal step indicator bar at the top of the stepper. It displays one item per form step plus a final **Preview & Summary** item. Completed steps show a checkmark and are clickable to navigate back. + +> [!NOTE] +> This is an internal sub-component of `ue-stepper-form`. You do not use it directly. + +## Behaviour + +- Each step item shows its `title` from the `forms` array. +- A step is marked **complete** when `activeStep` is greater than that step's index. +- Clicking a completed step's title emits `step-click` with the 1-based step number. The parent (`StepperForm`) only allows backward navigation — forward jumps to incomplete steps are blocked. +- The final **Preview & Summary** item is always the last entry and has no click handler. + +## Props + +| Prop | Type | Required | Description | +|---|---|---|---| +| `forms` | `Array` | Yes | The same `forms` array passed to `ue-stepper-form`. Each entry must have a `title`. | +| `activeStep` | `Number` | Yes | The currently active 1-based step index. | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `step-click` | `Number` | 1-based index of the step the user clicked. Only emitted for completed steps (backward navigation). | + +## Visual States + +| Condition | Appearance | +|---|---| +| `activeStep > i+1` | Step is complete — checkmark icon, title highlighted in primary colour | +| `activeStep === i+1` | Step is active — title highlighted in primary colour, bold | +| `activeStep < i+1` | Step is upcoming — default muted appearance | diff --git a/docs/src/pages/guide/components/stepper/stepper-preview.md b/docs/src/pages/guide/components/stepper/stepper-preview.md new file mode 100644 index 000000000..6f9e5addc --- /dev/null +++ b/docs/src/pages/guide/components/stepper/stepper-preview.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 4 +sidebarTitle: Stepper Preview +--- + +# StepperPreview + +`StepperPreview` is the main-column content for the final step. It shows two areas: + +1. **Formatted preview cards** — a responsive grid of `ue-configurable-card` tiles built from `previewNotations`. +2. **Final form selectable cards** — fetched items from `finalFormFields` that the user can add or remove from the payload before submitting. + +> [!NOTE] +> This is an internal sub-component of `ue-stepper-form`. You do not use it directly. + +## Behaviour + +- `formattedPreview` is passed in already-formatted from `StepperForm` via `NotationUtil.formattedPreview`. +- Each card in `previewFormData` (fetched via `finalFormFields[n].endpoint`) renders a `ue-configurable-card` with a toggle button (`mdi-plus` / `mdi-minus`). +- Clicking the toggle button emits `final-form-action` with `{ index, event }` where `event` is the new boolean selected state. +- Items present in `protectedLastStepModel` are rendered as read-only (the toggle is disabled) — this is used when editing an existing record and some items were already committed. +- Card appearance: selected items get `bg-primary`; unselected items get `bg-grey-lighten-5`. + +## Props + +| Prop | Type | Required | Default | Description | +|---|---|---|---|---| +| `formattedPreview` | `Array` | No | `[]` | Pre-formatted card data for the summary grid (top section). Each entry is a `ue-configurable-card` props object. | +| `previewFormData` | `Array` | No | `[]` | Fetched selectable items for the final form section (bottom section). | +| `lastStepModel` | `Object` | Yes | — | Object holding currently selected values, keyed by `fieldName`. | +| `protectedLastStepModel` | `Object` | No | `{}` | Initial model values that should be read-only. | +| `finalFormTitle` | `String` | Yes | — | Heading above the selectable cards. | +| `finalFormSubtitle` | `String` | No | `null` | Sub-heading below `finalFormTitle`. | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `final-form-action` | `{ index: Number, event: Boolean }` | Emitted when a card's toggle button is clicked. `index` is the item's position in `previewFormData`; `event` is `true` (add) or `false` (remove). | + +## Card Item Structure + +Each entry in `previewFormData` is a fetched and transformed item. The relevant fields used by `StepperPreview`: + +| Field | Description | +|---|---| +| `fieldName` | Key in `lastStepModel` where the selection is stored | +| `form_card_items` | Array of values for each card column (name, description, tags, price, etc.) | +| `_fieldFormat` | `'id'` or an object map — controls how selected values are stored | +| `_fieldFormatSourceKey` | Key on the item used as the source for uniqueness checks (default `'id'`) | +| `_fieldFormatUniqueKey` | Key used when matching against object-format stored values (default `'id'`) | + +These fields are injected automatically by `StepperForm.handlePreviewFormField` based on the `finalFormFields` configuration. + +## Selection Logic + +| `_fieldFormat` value | Add behaviour | Remove behaviour | +|---|---|---| +| `String` (e.g. `'id'`) | Appends `item[fieldFormat]` to the array | Filters the value out of the array | +| `Object` (field map) | Builds a new object from the map and appends it | Finds by `_fieldFormatUniqueKey` and splices it out | diff --git a/docs/src/pages/guide/components/stepper/stepper-summary.md b/docs/src/pages/guide/components/stepper/stepper-summary.md new file mode 100644 index 000000000..092876b54 --- /dev/null +++ b/docs/src/pages/guide/components/stepper/stepper-summary.md @@ -0,0 +1,88 @@ +--- +sidebarPos: 3 +sidebarTitle: Stepper Summary +--- + +# StepperSummary + +`StepperSummary` is the right-column sidebar panel. During regular steps it shows per-step preview cards and a **Next** button. On the final step it renders the `summary.final` slot — by default a `StepperFinalSummary` card. + +> [!NOTE] +> This is an internal sub-component of `ue-stepper-form`. You do not use it directly. + +## Behaviour + +- When `isLastStep` is `false`, iterates over `forms` and renders either a custom `summary-form-{n}` slot or the default `ue-form-summary-item` for each step whose preview model is filled. +- Dividers are injected between filled summary items automatically. +- The **Next** button at the bottom emits `next-form` with `activeStep - 1` (0-based index), triggering validation in the parent. +- When `isLastStep` is `true`, the `summary.final` slot is rendered. The default implementation renders [`StepperFinalSummary`](./stepper-final-summary) and wires `complete-form` to its complete button. + +## Props + +| Prop | Type | Required | Description | +|---|---|---|---| +| `isLastStep` | `Boolean` | Yes | `true` when `activeStep > forms.length`. | +| `forms` | `Array` | Yes | The `forms` array from the parent stepper. | +| `activeStep` | `Number` | Yes | Currently active 1-based step. | +| `models` | `Array` | Yes | Array of per-step model objects. | +| `schemas` | `Array` | Yes | Array of per-step schema arrays. | +| `previewModel` | `Array` | Yes | Array of per-step preview data objects. | +| `previewTitles` | `Array` | Yes | Resolved title string for each step's summary card. | +| `isPreviewModelFilled` | `Function` | Yes | `(index: number) => boolean` — determines whether a step has preview data to display. | + +## Emits + +| Event | Payload | Description | +|---|---|---| +| `next-form` | `Number` | 0-based index of the current step. Triggers form validation and advance. | +| `complete-form` | — | Emitted when the final-step complete button is clicked. | + +## Slots + +### `summary.forms` + +Replaces the entire default per-step summary list (all steps at once): + +```html +<template #summary.forms> + <!-- fully custom summary list --> +</template> +``` + +### `summary-form-{n}` + +Replaces the default summary card for step `n` (1-based). Receives a scoped object: + +```html +<template #summary-form-2="{ title, model, schema, previewModel, index, order, length }"> + <p>{{ title }}: {{ model.company }}</p> +</template> +``` + +| Binding | Type | Description | +|---|---|---| +| `index` | `Number` | Zero-based step index | +| `order` | `Number` | One-based step number | +| `title` | `String` | Resolved preview title | +| `model` | `Object` | Form model for this step | +| `schema` | `Array` | Form schema for this step | +| `previewModel` | `Object` | Preview model for this step | +| `isPreviewModelFilled` | `Function` | `(index) => Boolean` | +| `length` | `Number` | Total number of steps | + +### `summary.final` + +Replaces the entire right-column content on the final step. Receives: + +```html +<template #summary.final="{ model, schema, previewModel, onComplete }"> + <!-- custom final panel, call onComplete() to submit --> +</template> +``` + +| Binding | Type | Description | +|---|---|---| +| `model` | `Array` | All step models | +| `schema` | `Array` | All step schemas | +| `previewModel` | `Array` | All preview models | +| `onComplete` | `Function` | Call to trigger form submission | diff --git a/docs/src/pages/guide/components/success.md b/docs/src/pages/guide/components/success.md new file mode 100644 index 000000000..8654805a7 --- /dev/null +++ b/docs/src/pages/guide/components/success.md @@ -0,0 +1,31 @@ +--- +sidebarPos: 26 +sidebarTitle: Success +--- +# Success + +`ue-success` renders a full-page success state with a check-circle icon, a title, a description, and a call-to-action button. + +## Usage + +```html +<ue-success + title="Payment Complete" + description="Your order has been placed successfully." + button_text="Go to Dashboard" + button_url="/dashboard" +/> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `title` | `String` | yes | Heading text displayed below the icon | +| `description` | `String` | yes | Body text below the heading | +| `button_text` | `String` | yes | Label for the action button | +| `button_url` | `String` | yes | `href` of the action button | + +## Behaviour + +The component uses a vertically and horizontally centred `v-container fill-height` layout. All four props are required — omitting any of them will cause Vue to emit a prop validation warning. diff --git a/docs/src/pages/guide/components/svg-icon.md b/docs/src/pages/guide/components/svg-icon.md new file mode 100644 index 000000000..1e99874d8 --- /dev/null +++ b/docs/src/pages/guide/components/svg-icon.md @@ -0,0 +1,28 @@ +--- +sidebarPos: 17 +sidebarTitle: SVG Icon +--- +# SVG Icon + +`ue-svg-icon` renders an inline SVG symbol using the `v-svg` directive. Use it to display icons from a sprite sheet that has been injected into the page. + +## Usage + +```html +<ue-svg-icon symbol="icon-logo" width="48" height="48" /> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `symbol` | `String` | yes | The SVG `<symbol>` ID to render | +| `width` | `String` | no | Width in pixels | +| `height` | `String` | no | Height in pixels | + +## Notes + +- The component uses the `v-svg` directive which resolves `symbol` against the application's SVG sprite sheet. +- Width and height are applied as inline styles on the wrapping `<span>`. +- If no `width`/`height` are provided, the SVG scales to fit its container (`max-width: 100%; max-height: 100%`). +- For standard MDI icons, use Vuetify's `v-icon` instead — `ue-svg-icon` is intended for custom brand or project-specific vector icons. diff --git a/docs/src/pages/guide/components/tab-groups.md b/docs/src/pages/guide/components/tab-groups.md index 14c8c751a..778035d60 100644 --- a/docs/src/pages/guide/components/tab-groups.md +++ b/docs/src/pages/guide/components/tab-groups.md @@ -1,5 +1,6 @@ --- -# sidebarPos: 3 +sidebarPos: 3 +sidebarTitle: Tab Groups --- # Tab Groups <Badge type="tip" text="^0.10.0" /> diff --git a/docs/src/pages/guide/components/table-binder.md b/docs/src/pages/guide/components/table-binder.md new file mode 100644 index 000000000..785493f58 --- /dev/null +++ b/docs/src/pages/guide/components/table-binder.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 36 +sidebarTitle: Table Binder +--- +# Table Binder + +`ue-table-binder` dynamically resolves a `ue-*` component by name and forwards a set of attributes to it. It is a thin bridge that lets server-driven configuration select which table-like component to render without hardcoding the component tag in the template. + +## Usage + +```html +<ue-table-binder + component-name="data-table" + :table-attributes="tableProps" +/> +``` + +This is equivalent to: + +```html +<ue-data-table v-bind="tableProps" /> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `componentName` | `String` | — | Suffix of the `ue-*` component to render (e.g. `'data-table'` renders `ue-data-table`) | +| `tableAttributes` | `Object` | yes | Props forwarded to the resolved component via `v-bind` | + +::: tip +`ue-table-binder` is useful in module config files where the table component may vary per context. Pass `componentName` from PHP and keep the Vue template generic. +::: diff --git a/docs/src/pages/guide/components/table-internals.md b/docs/src/pages/guide/components/table-internals.md new file mode 100644 index 000000000..341da45de --- /dev/null +++ b/docs/src/pages/guide/components/table-internals.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 43 +sidebarTitle: Table Internals +--- +# Table Internals + +These components are internal building blocks of `ue-data-table`. You do not use them directly — they are rendered automatically by the table. + +## `TableFormatterCell` + +Renders a single cell in the data table, applying the correct formatter strategy based on `col.formatter`. + +### Formatter strategies + +| Formatter value | Render | +|-----------------|--------| +| `'edit'` / `'activate'` | Clickable primary-colour text. Clicking calls `itemAction` | +| `'switch'` | `v-switch` that calls `itemAction` on change | +| `'dynamic'` | Delegates to `ue-dynamic-component-renderer` | +| Any other / array | Delegates to `ue-recursive-stuff` via `handleFormatter()` | + +### Key Props + +| Prop | Type | Description | +|------|------|-------------| +| `col` | `Object` | Column header definition (key, formatter, formatterName, isFormatting, hasCopy, target) | +| `item` | `Object` | The data record for the row | +| `cellValue` | any | Explicit cell value — overrides `item[col.key]` when set | +| `cellOptions` | `Object` | Extra cell config — currently supports `maxChars` for pre-shorten | +| `handleFormatter` | `Function` | Formatter resolution function provided by the table | +| `itemAction` | `Function` | Row action handler provided by the table | +| `clickableRow` | `Boolean` | True when the row itself is clickable (affects edit/activate cells) | +| `groupContext` | `Boolean` | True when the cell is inside a `group-by` header row | +| `disableTooltip` | `Boolean` | Skip `v-tooltip` — used in mobile/touch layouts | + +--- + +## `TableGroupHeaderRow` + +Renders the sticky group header row when `group-by` is active in `ue-data-table`. It shows the group value (with optional formatter), a row count badge, a collapse/expand toggle, and an optional select-all checkbox. + +### Key Props + +| Prop | Type | Description | +|------|------|-------------| +| `group` | `Object` | Vuetify group object (`value`, `depth`, `items`) | +| `columns` | `Array` | Full column definitions — used for `colspan` | +| `formatterColumn` | `Object\|null` | Column definition whose formatter should render the group value | +| `syntheticItem` | `Object` | Synthetic row object used by `TableFormatterCell` in group context | +| `handleFormatter` | `Function` | Formatter resolution function from the table | +| `itemAction` | `Function` | Row action handler | +| `showSelect` | `Boolean` | Show the group-level select checkbox | +| `disableFormatterTooltip` | `Boolean` | Disable tooltips on the formatter cell (mobile mode) | + +--- + +## `TableActions` (table/TableActions.vue) + +A standalone row of action buttons rendered in the table toolbar or below the table. Accepts an `actions` array in the same format as `ue-form`'s actions and renders each as a `v-btn` with optional badge, tooltip, and publish-switch variants. + +### Props + +| Prop | Type | Description | +|------|------|-------------| +| `actions` | `Array` | Action definitions (same shape as `ue-form` actions) | + +### Slots + +| Slot | Description | +|------|-------------| +| `prepend` | Content placed before the action buttons | diff --git a/docs/src/pages/guide/components/tabs.md b/docs/src/pages/guide/components/tabs.md index bd7ce6b0e..a70ccc6e0 100644 --- a/docs/src/pages/guide/components/tabs.md +++ b/docs/src/pages/guide/components/tabs.md @@ -1,12 +1,13 @@ --- -# sidebarPos: 3 +sidebarPos: 2 +sidebarTitle: Tabs --- # Tabs <Badge type="tip" text="^0.10.0" /> The `ue-tabs` component combines **v-tabs** and **v-tabs-window** components as a one component. You must pass items prop into the component for generating component tab structure. ## Usage -It has 'items' prop as object, every keys meet a tab, every values fill the tab-windows. +It has 'items' prop as object, every key meets a tab, every value fills the tab-windows. ``` php @php $items = [ diff --git a/docs/src/pages/guide/components/text-display.md b/docs/src/pages/guide/components/text-display.md new file mode 100644 index 000000000..9693237dd --- /dev/null +++ b/docs/src/pages/guide/components/text-display.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 11 +sidebarTitle: Text Display +--- +# Text Display + +The `ue-text-display` component renders a bold primary value with an optional smaller secondary value aligned to its baseline. Typical use cases are prices, totals, or any figure that needs a unit suffix. + +## Usage + +```html +<ue-text-display text="$2,500" sub-text="+ VAT" /> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `text` | `String` | yes | The main value to display (bold) | +| `subText` | `String` | no | A secondary label rendered in `text-body-1` aligned to the baseline | + +## Examples + +```html +<!-- Price with currency suffix --> +<ue-text-display text="€ 1,200" sub-text="/ month" /> + +<!-- Simple count --> +<ue-text-display text="42" /> + +<!-- Inside a stepper summary card --> +<ue-text-display class="text-h5 text-white" text="$2500" sub-text="+ VAT" /> +``` diff --git a/docs/src/pages/guide/components/title.md b/docs/src/pages/guide/components/title.md new file mode 100644 index 000000000..27180eac7 --- /dev/null +++ b/docs/src/pages/guide/components/title.md @@ -0,0 +1,59 @@ +--- +sidebarPos: 8 +sidebarTitle: Title +--- +# Title + +The `ue-title` component is a flexible, polymorphic text element for headings and labels. It converts typography props into Vuetify utility classes and renders as any HTML element via the `tag` prop. + +## Usage + +```html +<ue-title text="Section Heading" type="h4" weight="bold" color="primary" /> +``` + +## Props + +| Prop | Type | Default | Accepted values | +|------|------|---------|-----------------| +| `text` | `String` | — | Any string | +| `subTitle` | `String` | — | Any string | +| `tag` | `String` | `'div'` | `div`, `h1`–`h6` | +| `type` | `String` | `'body-1'` | `h1`–`h6`, `subtitle-1`, `subtitle-2`, `body-1`, `body-2`, `button`, `caption`, `overline` | +| `weight` | `String` | `'bold'` | `black`, `bold`, `medium`, `regular`, `light`, `thin` | +| `transform` | `String` | `'uppercase'` | `none`, `capitalize`, `lowercase`, `uppercase` | +| `color` | `String` | — | Vuetify color name or hex | +| `bg` | `String` | — | Vuetify background color name | +| `padding` | `String` | `'a-3'` | Vuetify spacing suffix, e.g. `'a-0'`, `'x-2'` | +| `margin` | `String` | `'a-0'` | Vuetify spacing suffix | +| `align` | `String` | `'start'` | `start`, `center`, `end` | +| `justify` | `String` | `'start'` | `start`, `center`, `end`, `space-between` | +| `textPosition` | `String` | `'left'` | `left`, `center`, `right` | +| `classes` | `String\|Array` | — | Extra CSS classes | + +## Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `default` | `{text}` | Replaces the default `<span>` with custom content | +| `right` | — | Content anchored to the right side of the title row | + +## Examples + +```html +<!-- Page section header --> +<ue-title text="Users" type="h3" weight="medium" transform="none" /> + +<!-- Caption label --> +<ue-title text="Last updated" type="caption" weight="regular" color="grey-darken-1" padding="a-0" /> + +<!-- Colored badge-style title --> +<ue-title text="Active" type="overline" color="success" bg="success-lighten-5" padding="x-2" /> + +<!-- With right slot --> +<ue-title text="Orders"> + <template #right> + <v-btn size="small" icon="mdi-refresh" /> + </template> +</ue-title> +``` diff --git a/docs/src/pages/guide/components/uploader.md b/docs/src/pages/guide/components/uploader.md new file mode 100644 index 000000000..657dbf411 --- /dev/null +++ b/docs/src/pages/guide/components/uploader.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 42 +sidebarTitle: Uploader +--- +# Uploader + +`ue-uploader` is the file upload widget used inside the media library modal. It wraps the FineUploader library and supports S3, Azure Blob Storage, and traditional server-side endpoints. + +## Usage + +This component is used internally by `ue-modal-media`. You do not normally instantiate it directly — open the media library through `ue-modal-media` instead. + +```html +<ue-uploader :type="mediaTypeConfig" /> +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `type` | `Object` | yes | Media type configuration object. Must contain an `uploaderConfig` key (see below) | + +## `uploaderConfig` shape + +| Key | Type | Description | +|-----|------|-------------| +| `endpointType` | `String` | `'s3'`, `'azure'`, or `'traditional'` | +| `endpoint` | `String` | Upload endpoint URL | +| `allowedExtensions` | `Array` | Permitted file extensions, e.g. `['jpg', 'png', 'pdf']` | +| `maxFileSize` | `Number` | Maximum file size in bytes | +| `maxConnections` | `Number` | Parallel upload limit (default: 5) | + +## Behaviour + +- Provides a click-to-browse button and a desktop drop-zone. +- Commits uploaded media to the Vuex media library store (`MEDIA_LIBRARY` mutations) as uploads complete. +- File names are sanitised before upload via `sanitizeFilename`. + +::: tip +Upload configuration is generated server-side by the Modularous media library service and passed down to `ue-modal-media`, which forwards it to `ue-uploader`. Refer to the Media Library setup guide for server-side configuration. +::: diff --git a/docs/src/pages/guide/components/well-print.md b/docs/src/pages/guide/components/well-print.md new file mode 100644 index 000000000..fc5cf20c0 --- /dev/null +++ b/docs/src/pages/guide/components/well-print.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 27 +sidebarTitle: Well Print +--- +# Well Print + +`ue-well-print` renders text with automatic URL linkification and newline-to-`<br>` conversion. Detected URLs become clickable `<a>` tags that open in a new tab. + +## Usage + +```html +<!-- via prop --> +<ue-well-print text="Visit https://example.com for details." /> + +<!-- via slot (text content is extracted and linkified) --> +<ue-well-print> + Check out https://example.com +</ue-well-print> +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `text` | `String` | `''` | Text to render. Takes precedence over slot content | +| `fullText` | `String` | `null` | Untruncated version of `text`. Used to recover full URLs when `text` is a truncated excerpt | +| `noLinkify` | `Boolean` | `false` | Disable URL detection — text is still formatted but links are not created | + +## Behaviour + +- Detects `https://`, `http://`, `www.`, and bare domain patterns. +- Relative/bare URLs are normalised to `https://` before being set as `href`. +- `fullText` is useful when displaying a short preview: the visible text may cut a URL mid-string, but the `href` is sourced from `fullText` so the link points to the correct destination. +- Uses `v-html` internally — never pass unsanitised user input. +- `white-space: pre-line` is applied, so newlines in the source text produce visible line breaks. diff --git a/docs/src/pages/guide/commands/Generators/create-test-laravel.md b/docs/src/pages/guide/console/check-collation.md similarity index 61% rename from docs/src/pages/guide/commands/Generators/create-test-laravel.md rename to docs/src/pages/guide/console/check-collation.md index 2578bb8dd..806ec3614 100644 --- a/docs/src/pages/guide/commands/Generators/create-test-laravel.md +++ b/docs/src/pages/guide/console/check-collation.md @@ -1,54 +1,52 @@ -# `Make Laravel Test` +# Check Collation -> Create a test file for laravel features or components +> Check database and connection collations. ## Command Information -- **Signature:** `modularity:make:laravel:test [--unit] [--] <module> <test>` -- **Alias:** `modularity:create:laravel:test` (deprecated, use `make:laravel:test`) -- **Category:** Generators - +- **Signature:** `modularity:db:check-collation <table>` +- **Category:** Console ## Examples -### With Arguments +### Check collation for a specific table ```bash -php artisan modularity:make:laravel:test MODULE TEST +php artisan modularity:db:check-collation users ``` -### With Options +### Check collation for a module's main table ```bash -php artisan modularity:make:laravel:test --unit +php artisan modularity:db:check-collation posts ``` -### Common Combinations +`modularity:db:check-collation` +-------------------------------- -```bash -php artisan modularity:make:laravel:test MODULE -``` +Outputs the current database collation, the active connection collation, and then a per-column collation listing for the given table. Useful for diagnosing charset mismatches that cause query errors when joining tables with different collations. -`modularity:make:laravel:test` --------------------------------- +**Example output:** -Create a test file for laravel features or components +``` +Database Collation: utf8mb4_unicode_ci +Connection Collation: utf8mb4_unicode_ci -### Usage +users table columns: +id: NULL +name: utf8mb4_unicode_ci +email: utf8mb4_unicode_ci +``` -* `modularity:make:laravel:test [--unit] [--] <module> <test>` +### Usage -Create a test file for laravel features or components +* `modularity:db:check-collation <table>` ### Arguments -#### `module` +#### `table` -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `test` +The name of the database table to inspect column collations for. * Is required: yes * Is array: no @@ -56,14 +54,6 @@ Create a test file for laravel features or components ### Options -#### `--unit` - -* Accept value: no -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `false` - #### `--help|-h` Display help for the given command. When no command is given display help for the list command @@ -132,4 +122,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/console/coverage/coverage-analyze.md b/docs/src/pages/guide/console/coverage/coverage-analyze.md new file mode 100644 index 000000000..1f983157c --- /dev/null +++ b/docs/src/pages/guide/console/coverage/coverage-analyze.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 2 +sidebarTitle: Coverage Analyze +--- + +# Coverage Analyze + +> Analyse a Clover XML coverage report and display per-file results, optionally comparing against a git branch. + +## Command Information + +- **Signature:** `coverage:analyze [--cloverName=] [--cloverDir=] [--files=*] [--threshold=0] [--git=] [--skip-magic] [--format=table]` +- **Category:** Coverage + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--cloverName` | `clover.xml` | Clover XML file name | +| `--cloverDir` | `storage/app` | Directory containing the Clover file | +| `--files` | _(all)_ | Filter to specific file paths (repeatable) | +| `--threshold` | `0` | Minimum coverage % to consider passing | +| `--git` | — | Branch/commit to diff coverage against | +| `--skip-magic` | `false` | Exclude magic methods from analysis | +| `--format` | `table` | Output format: `table`, `json`, or `list` | + +## Examples + +### Basic analysis + +```bash +php artisan coverage:analyze +``` + +### Filter to specific files + +```bash +php artisan coverage:analyze --files=app/Models/User.php --files=app/Services/PaymentService.php +``` + +### Set a minimum threshold + +```bash +php artisan coverage:analyze --threshold=80 +``` + +### Compare against another branch + +```bash +php artisan coverage:analyze --git=main +``` + +### Output as JSON + +```bash +php artisan coverage:analyze --format=json +``` + +## Related + +- [coverage:report](/guide/console/coverage/coverage-report) — generate full HTML/Markdown/JSON reports +- [coverage:pr:check](/guide/console/coverage/coverage-pr-check) — CI gate that fails on threshold breach diff --git a/docs/src/pages/guide/console/coverage/coverage-generate-tests.md b/docs/src/pages/guide/console/coverage/coverage-generate-tests.md new file mode 100644 index 000000000..8279475ff --- /dev/null +++ b/docs/src/pages/guide/console/coverage/coverage-generate-tests.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 3 +sidebarTitle: Coverage Test Generator +--- + +# Coverage Generate Tests + +> Scaffold missing PHPUnit/Pest test stubs for uncovered files — optionally using an AI provider to write real test bodies. + +## Command Information + +- **Signature:** `coverage:generate-tests [--ai] [--model=] [--api-key=] [--template=phpunit] [--interactive] [--delay=0] [--dry-run] [--cloverName=] [--cloverDir=] [--files=*] [--threshold=0]` +- **Category:** Coverage + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--ai` | `false` | Use an AI provider to write test bodies | +| `--model` | — | AI model identifier (see table below) | +| `--api-key` | — | API key for the AI provider | +| `--template` | `phpunit` | Fallback template: `phpunit` or `pest` | +| `--interactive` | `false` | Prompt before writing each file | +| `--delay` | `0` | Seconds to wait between AI requests (rate-limit safety) | +| `--dry-run` | `false` | Show what would be generated without writing files | +| `--cloverName` | `clover.xml` | Clover XML file name | +| `--cloverDir` | `storage/app` | Directory containing the Clover file | +| `--files` | _(all)_ | Filter to specific file paths (repeatable) | +| `--threshold` | `0` | Only generate tests for files below this coverage % | + +## Supported AI Providers + +| Provider | Example `--model` value | +|----------|------------------------| +| Anthropic (Claude) | `claude-3-5-sonnet-20241022` | +| Google Gemini | `gemini-1.5-pro` | +| OpenAI | `gpt-4o` | +| Ollama (local) | `llama3.2` | + +The provider is inferred from the model name. For Ollama no API key is required. + +## Examples + +### Scaffold stubs using the default PHPUnit template + +```bash +php artisan coverage:generate-tests +``` + +### Use Claude to write real tests for uncovered files + +```bash +php artisan coverage:generate-tests --ai --model=claude-3-5-sonnet-20241022 --api-key=sk-ant-... +``` + +### Preview output without writing files + +```bash +php artisan coverage:generate-tests --ai --model=gpt-4o --api-key=sk-... --dry-run +``` + +### Generate Pest stubs with interactive confirmation + +```bash +php artisan coverage:generate-tests --template=pest --interactive +``` + +### Only generate tests for files below 50 % coverage + +```bash +php artisan coverage:generate-tests --threshold=50 --ai --model=gemini-1.5-pro --api-key=... +``` + +## Related + +- [coverage:analyze](/guide/console/coverage/coverage-analyze) — identify which files need tests +- [coverage:report](/guide/console/coverage/coverage-report) — visualise results after running tests diff --git a/docs/src/pages/guide/console/coverage/coverage-pr-check.md b/docs/src/pages/guide/console/coverage/coverage-pr-check.md new file mode 100644 index 000000000..80a31395b --- /dev/null +++ b/docs/src/pages/guide/console/coverage/coverage-pr-check.md @@ -0,0 +1,61 @@ +--- +sidebarPos: 4 +sidebarTitle: Coverage PR Check +--- + +# Coverage PR Check + +> Compare coverage against a base branch and optionally fail the process if coverage drops below the threshold — designed as a CI gate. + +## Command Information + +- **Signature:** `coverage:pr:check [--cloverName=] [--cloverDir=] [--branch=main] [--threshold=0] [--fail]` +- **Category:** Coverage + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--cloverName` | `clover.xml` | Clover XML file name | +| `--cloverDir` | `storage/app` | Directory containing the Clover file | +| `--branch` | `main` | Base branch to compare against | +| `--threshold` | `0` | Minimum coverage % required | +| `--fail` | `false` | Exit with non-zero code if threshold not met | + +## What It Does + +The command reads the Clover XML report, resolves coverage for the current working tree, and compares it against the specified base branch. It displays a summary table showing whether coverage increased, decreased, or held steady relative to the base. + +When `--fail` is set and the coverage falls below `--threshold`, the command exits with a failure code. This is the intended use in CI pipelines where a failing exit code blocks the merge. + +## Examples + +### Basic PR check + +```bash +php artisan coverage:pr:check +``` + +### Check against a non-default base branch + +```bash +php artisan coverage:pr:check --branch=develop +``` + +### Enforce 80 % coverage and fail the CI step + +```bash +php artisan coverage:pr:check --branch=main --threshold=80 --fail +``` + +### GitHub Actions example + +```yaml +- name: Coverage gate + run: php artisan coverage:pr:check --branch=main --threshold=75 --fail +``` + +## Related + +- [coverage:analyze](/guide/console/coverage/coverage-analyze) — detailed per-file analysis +- [coverage:report](/guide/console/coverage/coverage-report) — generate full coverage reports diff --git a/docs/src/pages/guide/console/coverage/coverage-report.md b/docs/src/pages/guide/console/coverage/coverage-report.md new file mode 100644 index 000000000..0df5e0388 --- /dev/null +++ b/docs/src/pages/guide/console/coverage/coverage-report.md @@ -0,0 +1,67 @@ +--- +sidebarPos: 5 +sidebarTitle: Coverage Report +--- + +# Coverage Report + +> Generate JSON, Markdown, and/or HTML coverage reports from a Clover XML file. + +## Command Information + +- **Signature:** `coverage:report [--output=storage/app/coverage] [--format=*] [--git=] [--files=*] [--threshold=0] [--open]` +- **Category:** Coverage + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--output` | `storage/app/coverage` | Output directory for generated reports | +| `--format` | _(all)_ | Report formats to generate: `json`, `markdown`, `html` (repeatable) | +| `--git` | — | Branch/commit to diff coverage against | +| `--files` | _(all)_ | Filter to specific file paths (repeatable) | +| `--threshold` | `0` | Minimum coverage % — printed in the report header | +| `--open` | `false` | Open the HTML report in the default browser after generation | + +## What It Does + +Reads the Clover XML report and writes one or more report files to `--output`. If no `--format` is specified all three formats are generated. The HTML report is a standalone, self-contained file suitable for sharing as a build artefact. + +When `--open` is passed the command attempts to open `index.html` in the system's default browser. + +## Examples + +### Generate all formats + +```bash +php artisan coverage:report +``` + +### Generate only Markdown (e.g. for GitHub summaries) + +```bash +php artisan coverage:report --format=markdown +``` + +### Generate HTML and open immediately + +```bash +php artisan coverage:report --format=html --open +``` + +### Write to a custom directory + +```bash +php artisan coverage:report --output=public/coverage +``` + +### Include a threshold in the report and diff vs. branch + +```bash +php artisan coverage:report --threshold=80 --git=main +``` + +## Related + +- [coverage:analyze](/guide/console/coverage/coverage-analyze) — inline terminal analysis +- [coverage:pr:check](/guide/console/coverage/coverage-pr-check) — CI threshold gate diff --git a/docs/src/pages/guide/console/coverage/coverage-watch.md b/docs/src/pages/guide/console/coverage/coverage-watch.md new file mode 100644 index 000000000..370d8bd32 --- /dev/null +++ b/docs/src/pages/guide/console/coverage/coverage-watch.md @@ -0,0 +1,64 @@ +--- +sidebarPos: 6 +sidebarTitle: Coverage Watch +--- + +# Coverage Watch + +> Poll a Clover XML file and display a diff whenever coverage changes — useful for a live TDD feedback loop. + +## Command Information + +- **Signature:** `coverage:watch [--cloverName=] [--cloverDir=] [--interval=5]` +- **Category:** Coverage + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--cloverName` | `clover.xml` | Clover XML file name to watch | +| `--cloverDir` | `storage/app` | Directory containing the Clover file | +| `--interval` | `5` | Polling interval in seconds | + +## What It Does + +The command reads the Clover file on startup and saves the initial coverage snapshot. It then re-reads the file every `--interval` seconds. When coverage numbers change — either because tests were re-run or because the file was regenerated — it prints a diff showing which files improved or regressed. + +Press `Ctrl+C` to stop watching. + +## Examples + +### Watch with default settings + +```bash +php artisan coverage:watch +``` + +### Check every 2 seconds + +```bash +php artisan coverage:watch --interval=2 +``` + +### Watch a custom Clover file + +```bash +php artisan coverage:watch --cloverDir=build --cloverName=coverage.xml +``` + +## TDD Workflow + +Keep the watcher running in a terminal split while you write tests: + +```bash +# Terminal 1 — run tests continuously +php artisan test --coverage-clover=storage/app/clover.xml --watch + +# Terminal 2 — watch coverage change in real time +php artisan coverage:watch --interval=3 +``` + +## Related + +- [coverage:analyze](/guide/console/coverage/coverage-analyze) — one-shot analysis +- [coverage:generate-tests](/guide/console/coverage/coverage-generate-tests) — scaffold missing tests diff --git a/docs/src/pages/guide/console/coverage/overview.md b/docs/src/pages/guide/console/coverage/overview.md new file mode 100644 index 000000000..709466ee8 --- /dev/null +++ b/docs/src/pages/guide/console/coverage/overview.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 6 +sidebarTitle: Overview +sidebarGroupTitle: Coverage +--- + +# Coverage Commands + +The Coverage command group wraps the `CoverageService` / `Coverage` facade to analyse Clover XML reports, generate reports, check PR thresholds, watch live coverage changes, and AI-generate missing tests. + +| Command | Description | +|---------|-------------| +| [coverage:analyze](/guide/console/coverage/coverage-analyze) | Analyse a Clover XML report and display per-file coverage | +| [coverage:pr:check](/guide/console/coverage/coverage-pr-check) | Compare coverage against a base branch and fail if below threshold | +| [coverage:report](/guide/console/coverage/coverage-report) | Generate JSON, Markdown, or HTML coverage reports | +| [coverage:generate-tests](/guide/console/coverage/coverage-generate-tests) | Scaffold missing tests — optionally via AI provider | +| [coverage:watch](/guide/console/coverage/coverage-watch) | Poll a Clover file and display diffs as coverage changes | + +## Prerequisites + +All commands require a Clover XML file produced by PHPUnit or Pest: + +```bash +php artisan test --coverage-clover=storage/app/clover.xml +``` + +The default file name and directory can be overridden per command with `--cloverName` and `--cloverDir`. + +## Common Workflows + +### Generate a local coverage report + +```bash +php artisan test --coverage-clover=storage/app/clover.xml +php artisan modularity:coverage:analyze # quick per-file summary +php artisan modularity:coverage:report # JSON / markdown / HTML rendering +``` + +### Gate a pull request on coverage + +```bash +php artisan modularity:coverage:pr:check --threshold=80 --base=main +``` + +Fails with a non-zero exit code when PR coverage drops below the threshold — wire it into CI. + +### Scaffold missing tests (optionally AI-assisted) + +```bash +php artisan modularity:coverage:generate-tests +``` + +Creates PHPUnit/Pest test stubs for uncovered methods. When an AI provider is configured, `--ai` fills in assertions; otherwise the stubs are empty scaffolds. + +### Watch coverage during TDD + +```bash +php artisan modularity:coverage:watch +``` + +Polls the Clover file and prints a diff whenever coverage changes — run alongside your test runner for fast feedback. + +## Related + +- [CoverageService](/system-reference/backend/services/overview) — underlying service +- [Coverage facade](/system-reference/backend/facades/overview) — programmatic access diff --git a/docs/src/pages/guide/console/docs/docs-audit.md b/docs/src/pages/guide/console/docs/docs-audit.md new file mode 100644 index 000000000..0adaff5b2 --- /dev/null +++ b/docs/src/pages/guide/console/docs/docs-audit.md @@ -0,0 +1,94 @@ +--- +sidebarPos: 2 +sidebarTitle: Docs Audit +--- + +# Docs Audit + +> Audit source files against documentation pages and report gaps. + +## Command Information + +- **Signature:** `modularity:docs:audit [--section=SECTION] [--fail-on-missing]` +- **Category:** Docs + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--section=` | _(all sections)_ | Filter to a specific section label (e.g. `Entities`, `Controllers`, `Hydrates`). Case-insensitive substring match. | +| `--fail-on-missing` | `false` | Exit with code 1 when any source file is undocumented — use this in CI. | + +## What It Does + +Walks a registry of source-to-docs mappings defined in `DocsAuditCommand::sections()`. For each section, it scans the source directory for `.php` files, kebab-cases the class name, and checks whether a matching `.md` file exists in the paired docs directory. The final output is a summary table plus a per-section list of missing pages and a coverage percentage. + +`index.md` files in the docs directory are ignored, since those are section overviews rather than per-class pages. + +### Tracked sections + +The audit ships with a registry covering the backend reference docs: Entities, Entity Enums, Entity Scopes, Entity Traits, Controllers, Middleware, HTTP Requests, View Composers, Facades, Helpers, Providers, Events, Notifications, Generators, Hydrates, Core Services, Package Traits, Contracts, Exceptions, Transformers, Activators, Brokers, and Repository Traits. + +To track a new section, add an entry to the `sections()` array in `src/Console/Docs/DocsAuditCommand.php`: + +```php +[ + 'label' => 'My Section', + 'source' => 'src/MyDir', + 'docs' => 'docs/src/pages/system-reference/backend/my-section', + 'recursive' => true, // optional, default false + 'exclude_dirs' => ['Traits'], // optional +], +``` + +## Examples + +### Full audit + +```bash +php artisan modularity:docs:audit +``` + +### Audit a single section + +```bash +php artisan modularity:docs:audit --section=Entities +php artisan modularity:docs:audit --section=hydrates +``` + +### CI gate + +```bash +php artisan modularity:docs:audit --fail-on-missing +``` + +Exits with a non-zero status when undocumented classes exist, failing the build. + +## Output + +``` + INFO Modularous Documentation Audit + + Package root: /path/to/packages/modularous + ++-----------------+--------------+------------+---------------+ +| Section | Source Files | Documented | Status | ++-----------------+--------------+------------+---------------+ +| Entities | 12 | 12 | ✓ Complete | +| Controllers | 18 | 15 | ✗ 3 missing | +... ++-----------------+--------------+------------+---------------+ + + INFO Coverage: 142/150 files (95%) + + WARN Missing documentation: + + Controllers ───────── 3 file(s) + • GlideController — src/Http/Controllers/GlideController.php + ... +``` + +## Related + +- [generate-command-docs](./generate-command-docs) — scaffold Markdown pages for Artisan commands +- [Backend Reference](/system-reference/backend/overview) — the docs tree this command audits diff --git a/docs/src/pages/guide/console/docs/generate-command-docs.md b/docs/src/pages/guide/console/docs/generate-command-docs.md new file mode 100644 index 000000000..09bd06d67 --- /dev/null +++ b/docs/src/pages/guide/console/docs/generate-command-docs.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 3 +sidebarTitle: Generate Command Docs +--- + +# Generate Command Docs + +> Auto-generate Markdown reference pages for all registered `modularity:*` and `mod:*` Artisan commands. + +## Command Information + +- **Signature:** `modularity:generate:command:docs [--output=] [--f|force]` +- **Category:** Generators + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--output=` | `docs/src/pages/advanced-guide/commands` (vendor path) | Directory where Markdown files are written | +| `--force` / `-f` | `false` | Overwrite existing files | + +## What It Does + +Iterates over every command registered with the Laravel Kernel, filters to those whose name starts with `modularity:` or `mod:`, then writes one `.md` file per command. Each file is generated from the command's signature, description, arguments, and options — the same data that populates the auto-generated boilerplate sections in this documentation. + +This command is the tool used to bootstrap the initial command docs. Manually curated pages should be written or edited afterward. + +## Examples + +```bash +# Generate to the default output path +php artisan modularity:generate:command:docs + +# Generate to a custom directory +php artisan modularity:generate:command:docs --output=docs/commands + +# Regenerate and overwrite existing files +php artisan modularity:generate:command:docs --force +``` + +## Related + +- [Commands Overview](/guide/console/overview) — full command reference diff --git a/docs/src/pages/guide/console/docs/overview.md b/docs/src/pages/guide/console/docs/overview.md new file mode 100644 index 000000000..ff42b71a9 --- /dev/null +++ b/docs/src/pages/guide/console/docs/overview.md @@ -0,0 +1,40 @@ +--- +sidebarPos: 8 +sidebarTitle: Overview +sidebarGroupTitle: Docs +--- + +# Docs Commands + +Generate and audit the Modularous documentation. These commands correspond to the PHP classes under `src/Console/Docs/`. + +| Command | Signature | Description | +|---------|-----------|-------------| +| [docs:audit](./docs-audit) | `modularity:docs:audit` | Audit source files against documentation pages and report gaps | +| [generate-command-docs](./generate-command-docs) | `modularity:generate:command:docs` | Auto-generate Markdown reference pages for all registered `modularity:*` commands | + +## Common Workflows + +### Check documentation coverage + +```bash +php artisan modularity:docs:audit +``` + +Prints a per-section table showing how many source files are documented and lists what's missing. See [docs:audit](./docs-audit) for filter and CI options. + +### Bootstrap or refresh command pages + +```bash +php artisan modularity:generate:command:docs --force +``` + +Walks every registered `modularity:*` command and writes a boilerplate `.md` page per command. Use `--force` to overwrite existing files. See [generate-command-docs](./generate-command-docs). + +### Enforce documentation in CI + +```bash +php artisan modularity:docs:audit --fail-on-missing +``` + +Exits with code 1 when undocumented source files exist — wire this into a CI job to block merges that add undocumented classes. diff --git a/docs/src/pages/guide/commands/Generators/create-feature.md b/docs/src/pages/guide/console/flush/flush-filepond.md similarity index 60% rename from docs/src/pages/guide/commands/Generators/create-feature.md rename to docs/src/pages/guide/console/flush/flush-filepond.md index 603d6cc0f..3a054e0c8 100644 --- a/docs/src/pages/guide/commands/Generators/create-feature.md +++ b/docs/src/pages/guide/console/flush/flush-filepond.md @@ -1,43 +1,57 @@ -# `Create Feature` +--- +sidebarPos: 3 +sidebarTitle: Flush Filepond +--- -> Create a modularity feature +# Flush Filepond -## Command Information +> Flush temporary FilePond uploads. -- **Signature:** `modularity:create:feature [<name>]` -- **Category:** Generators +## Command Information +- **Signature:** `modularity:flush:filepond [<days>]` +- **Alias:** `modularity:filepond:flush` +- **Category:** Flush ## Examples -### With Arguments +### Flush filepond files older than 7 days (default) + +```bash +php artisan modularity:flush:filepond +``` + +### Flush filepond files older than 3 days ```bash -php artisan modularity:create:feature NAME +php artisan modularity:flush:filepond 3 ``` +### Flush all filepond files (0 days) -`modularity:create:feature` +```bash +php artisan modularity:flush:filepond 0 +``` + +`modularity:flush:filepond` --------------------------- -Create a modularity feature +Deletes temporary FilePond upload files that are older than the specified number of days, then clears empty FilePond staging folders. Useful as a scheduled task to prevent disk bloat from abandoned uploads. ### Usage -* `modularity:create:feature [<name>]` -* `mod:c:feature` - -Create a modularity feature +* `modularity:flush:filepond [<days>]` +* `modularity:filepond:flush [<days>]` ### Arguments -#### `name` +#### `days` -The name of the feature to be created. +The number of days to keep temporary FilePond files. Files older than this are deleted. * Is required: no * Is array: no -* Default: `NULL` +* Default: `7` ### Options @@ -109,4 +123,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/commands/Generators/make-controller-api.md b/docs/src/pages/guide/console/flush/flush-sessions.md similarity index 56% rename from docs/src/pages/guide/commands/Generators/make-controller-api.md rename to docs/src/pages/guide/console/flush/flush-sessions.md index 5dff00753..ba79c3603 100644 --- a/docs/src/pages/guide/commands/Generators/make-controller-api.md +++ b/docs/src/pages/guide/console/flush/flush-sessions.md @@ -1,72 +1,65 @@ -# `Make Controller Api` +--- +sidebarPos: 4 +sidebarTitle: Flush Sessions +--- -> Create API Controller with repository for specified module. +# Flush Sessions -## Command Information +> Flush all user sessions. -- **Signature:** `modularity:make:controller:api [--example [EXAMPLE]] [--] <module> <name>` -- **Category:** Generators +## Command Information +- **Signature:** `modularity:flush:sessions [--driver[=DRIVER]]` +- **Alias:** `modularity:session:flush` +- **Category:** Flush ## Examples -### With Arguments +### Flush sessions using the app's configured driver ```bash -php artisan modularity:make:controller:api MODULE NAME +php artisan modularity:flush:sessions ``` -### With Options +### Flush database sessions explicitly ```bash -php artisan modularity:make:controller:api --example=EXAMPLE +php artisan modularity:flush:sessions --driver=database ``` -### Common Combinations +### Flush file sessions explicitly ```bash -php artisan modularity:make:controller:api MODULE +php artisan modularity:flush:sessions --driver=file ``` -`modularity:make:controller:api` --------------------------------- - -Create API Controller with repository for specified module. - -### Usage - -* `modularity:make:controller:api [--example [EXAMPLE]] [--] <module> <name>` - -Create API Controller with repository for specified module. - -### Arguments +### Flush both database and file sessions -#### `module` - -The name of module will be used. +```bash +php artisan modularity:flush:sessions --driver=all +``` -* Is required: yes -* Is array: no -* Default: `NULL` +`modularity:flush:sessions` +--------------------------- -#### `name` +Clears all active user sessions. The driver is read from `config('session.driver')` when `--driver` is not specified. Supports `database` (truncates the sessions table), `file` (deletes all files in the session path), and `all` (both). -The name of the controller class. +### Usage -* Is required: yes -* Is array: no -* Default: `NULL` +* `modularity:flush:sessions [--driver[=DRIVER]]` +* `modularity:session:flush [--driver[=DRIVER]]` ### Options -#### `--example` +#### `--driver` -An example option. +The session driver to flush. Reads from `session.driver` config when omitted. * Accept value: yes * Is value required: no * Is multiple: no * Is negatable: no +* Allowed values: `database`, `file`, `all` * Default: `NULL` #### `--help|-h` @@ -137,4 +130,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/console/flush/flush.md b/docs/src/pages/guide/console/flush/flush.md new file mode 100644 index 000000000..c1e8e3dcc --- /dev/null +++ b/docs/src/pages/guide/console/flush/flush.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 2 +sidebarTitle: Flush +--- + +# Flush + +> Flush all Modularous caches and display current cache versions. + +::: warning Hidden command +This command has `$hidden = true` and does not appear in `php artisan list`. +::: + +## Command Information + +- **Signature:** `modularity:flush` +- **Category:** Flush + +## What It Does + +Calls `Modularity::clearCache()` to wipe all Modularous cache entries, then runs `modularity:cache:versions` to print the refreshed version numbers — a quick confirmation that the flush succeeded. + +## Example + +```bash +php artisan modularity:flush +``` + +## Related + +- [flush:filepond](./flush-filepond) — delete orphaned FilePond temporary uploads +- [flush:sessions](./flush-sessions) — clear session data +- [cache:clear](/guide/console/cache/cache-clear) — fine-grained cache clearing by module/type diff --git a/docs/src/pages/guide/console/flush/overview.md b/docs/src/pages/guide/console/flush/overview.md new file mode 100644 index 000000000..d85d11b96 --- /dev/null +++ b/docs/src/pages/guide/console/flush/overview.md @@ -0,0 +1,50 @@ +--- +sidebarPos: 9 +sidebarTitle: Overview +sidebarGroupTitle: Flush +--- + +# Flush Commands + +Flush runtime state — caches, FilePond temporary uploads, and sessions. These commands complement the [Cache commands](../cache/overview): the Cache group manages Modularous own versioned cache, while Flush clears broader runtime artefacts that accumulate during development and operation. + +| Command | Signature | Description | +|---------|-----------|-------------| +| [flush](./flush) | `modularity:flush` | Flush all Modularous caches | +| [flush:filepond](./flush-filepond) | `modularity:flush:filepond` | Delete orphaned FilePond temporary files | +| [flush:sessions](./flush-sessions) | `modularity:flush:sessions` | Clear session data (supports multiple drivers) | + +## Common Workflows + +### Scheduled housekeeping + +Add to your scheduler (`app/Console/Kernel.php` or `routes/console.php`): + +```php +Schedule::command('modularity:flush:filepond')->daily(); +Schedule::command('modularity:flush:sessions')->weekly(); +``` + +FilePond leaves temporary upload chunks when users abandon forms — clear them daily. Sessions usually self-expire but can accumulate in the `file` driver. + +### After clearing data during local dev + +```bash +php artisan modularity:flush +php artisan modularity:flush:filepond +``` + +Resets caches and removes abandoned upload chunks so the UI is in a clean state. + +### Force logout everyone + +```bash +php artisan modularity:flush:sessions +``` + +Useful after a security-sensitive change (role permissions, auth config). All active sessions are invalidated; users must log in again. + +## Related + +- [Cache commands](../cache/overview) — targeted clear/warm/inspect +- [HasFileponds](/guide/generics/file-storage-with-filepond) — what `flush:filepond` works against diff --git a/docs/src/pages/guide/commands/Generators/create-command.md b/docs/src/pages/guide/console/generators/create-command.md similarity index 97% rename from docs/src/pages/guide/commands/Generators/create-command.md rename to docs/src/pages/guide/console/generators/create-command.md index b857067e9..944631053 100644 --- a/docs/src/pages/guide/commands/Generators/create-command.md +++ b/docs/src/pages/guide/console/generators/create-command.md @@ -1,8 +1,8 @@ --- -sidebarPos: 20 +sidebarPos: 2 --- -# make:command +# Create Command Create a new console command. Lives in `Console/Make/` (class: `MakeConsoleCommand`). diff --git a/docs/src/pages/guide/console/generators/create-feature.md b/docs/src/pages/guide/console/generators/create-feature.md new file mode 100644 index 000000000..6c3ce950e --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-feature.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 3 +sidebarTitle: Create Feature +--- + +# Create Feature + +> Interactively scaffold a cross-cutting feature — optionally generating a model trait, a repository trait, or both. + +## Command Information + +- **Signature:** `modularity:make:feature [name?]` +- **Aliases:** `modularity:create:feature`, `mod:c:feature` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | No | Feature name. If omitted, you are prompted interactively | + +## What It Does + +Asks (via interactive prompts) whether to create: +- A **repository trait** — delegates to `modularity:make:repository:trait` +- A **model trait** — delegates to `modularity:make:model:trait` + +Both are generated with the same StudlyCase name. Use this when a new feature requires behaviour spread across the model and repository layers. + +## Examples + +```bash +# Interactive — prompts for name and which traits to create +php artisan modularity:make:feature + +# Provide the name upfront +php artisan modularity:make:feature HasAnalytics +``` + +## Related + +- [create:model-trait](./create-model-trait) +- [create:repository-trait](./create-repository-trait) diff --git a/docs/src/pages/guide/console/generators/create-input-hydrate.md b/docs/src/pages/guide/console/generators/create-input-hydrate.md new file mode 100644 index 000000000..221730425 --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-input-hydrate.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 4 +sidebarTitle: Create Input Hydrate +--- + +# Create Input Hydrate + +> Generate a Hydrate class that defines the input schema for a module's form fields. + +## Command Information + +- **Signature:** `modularity:make:input:hydrate <name>` +- **Aliases:** `modularity:create:input:hydrate`, `mod:c:input:hydrate` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | Yes | Name of the Hydrate class (e.g. `Product` → generates `ProductHydrate`) | + +## What It Does + +Creates `{StudlyName}Hydrate.php` from the `input-hydrate.stub` template. The generated class defines the input schema — field types, labels, validation rules, and connector sources — used by `FormBase` to render the module's create/edit form. + +## Examples + +```bash +# Generate ProductHydrate +php artisan modularity:make:input:hydrate Product + +# Short alias +php artisan mod:c:input:hydrate Product +``` + +## Output + +Creates the Hydrate class at the path configured for the target module, typically: + +``` +Modules/{Module}/Http/Controllers/Hydrates/{Name}Hydrate.php +``` + +## Related + +- [make:module](/guide/console/generators/make-module) — generates the Hydrate as part of a full module scaffold +- [Hydrates reference](/system-reference/hydrates) — full schema contract for Hydrate classes diff --git a/docs/src/pages/guide/console/generators/create-model-trait.md b/docs/src/pages/guide/console/generators/create-model-trait.md new file mode 100644 index 000000000..82d6bdbae --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-model-trait.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 5 +sidebarTitle: Create Model Trait +--- + +# Create Model Trait + +> Generate a reusable Eloquent model trait stub. + +## Command Information + +- **Signature:** `modularity:make:model:trait {name}` +- **Aliases:** `modularity:create:model:trait`, `mod:c:model:trait` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | Yes | StudlyCase trait name (e.g. `HasAnalytics` → generates `HasAnalyticsTrait.php`) | + +## What It Does + +Creates a PHP trait file from a stub template with the StudlyCase name applied. Use this to extract reusable Eloquent behaviour (scopes, accessors, relationships) into a standalone trait that can be mixed into any model. + +## Examples + +```bash +php artisan modularity:make:model:trait HasAnalytics +php artisan mod:c:model:trait HasPricing +``` + +## Output + +Generates the trait file at: + +``` +Modules/{Module}/Traits/{StudlyName}Trait.php +``` + +## Related + +- [create:repository-trait](./create-repository-trait) — same pattern for repository traits +- [Entity Traits reference](/system-reference/backend/entity-traits/overview) — built-in model traits shipped with Modularity diff --git a/docs/src/pages/guide/console/generators/create-repository-trait.md b/docs/src/pages/guide/console/generators/create-repository-trait.md new file mode 100644 index 000000000..9c529a391 --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-repository-trait.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 6 +sidebarTitle: Create Repository Trait +--- + +# Create Repository Trait + +> Generate a reusable repository trait stub. + +## Command Information + +- **Signature:** `modularity:make:repository:trait {name}` +- **Aliases:** `modularity:create:repository:trait`, `mod:c:repo:trait` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | Yes | StudlyCase trait name (e.g. `HasSearch` → generates `HasSearchTrait.php`) | + +## What It Does + +Creates a PHP trait file from a stub template with the StudlyCase name applied. Use this to extract reusable query logic (custom scopes, filter methods, bulk operations) into a standalone trait that can be mixed into any repository. + +## Examples + +```bash +php artisan modularity:make:repository:trait HasSearch +php artisan mod:c:repo:trait HasBulkActions +``` + +## Output + +Generates the trait file at: + +``` +Modules/{Module}/Repositories/Traits/{StudlyName}Trait.php +``` + +## Related + +- [create:model-trait](./create-model-trait) — same pattern for model traits +- [Repository Traits reference](/system-reference/backend/repository-traits/overview) — built-in repository traits shipped with Modularous diff --git a/docs/src/pages/guide/console/generators/create-route-permissions.md b/docs/src/pages/guide/console/generators/create-route-permissions.md new file mode 100644 index 000000000..f81fe54af --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-route-permissions.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 7 +sidebarTitle: Create Route Permissions +--- + +# Create Route Permissions + +> Generate Spatie Permission records for all actions of a module route. + +## Command Information + +- **Signature:** `modularity:make:route:permissions [--route[=ROUTE]] <route>` +- **Aliases:** `modularity:create:route:permissions` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `route` | Yes | The module route name to generate permissions for (e.g. `products`) | + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--route` | `null` | Optional override for the route name (if different from the argument) | + +## What It Does + +Resolves the given route name via `RouteGenerator` and calls `createRoutePermissions()` to seed Spatie `permissions` records for every CRUD action defined on that route (index, create, store, show, edit, update, destroy, etc.). + +Run this after adding a new module or route to ensure the permission records exist before assigning them to roles. + +## Examples + +```bash +# Generate permissions for the "products" route +php artisan modularity:make:route:permissions products + +# Using the option form +php artisan modularity:make:route:permissions --route=products products +``` + +## Related + +- [make:route](/guide/console/generators/make-route) — add a route to an existing module +- [Spatie Laravel-Permission](https://spatie.be/docs/laravel-permission) — the underlying permissions library diff --git a/docs/src/pages/guide/commands/Generators/create-superadmin.md b/docs/src/pages/guide/console/generators/create-superadmin.md similarity index 95% rename from docs/src/pages/guide/commands/Generators/create-superadmin.md rename to docs/src/pages/guide/console/generators/create-superadmin.md index f3e63ec61..f81b73d15 100644 --- a/docs/src/pages/guide/commands/Generators/create-superadmin.md +++ b/docs/src/pages/guide/console/generators/create-superadmin.md @@ -1,11 +1,15 @@ -# `Create Superadmin` +# Create Superadmin > Creates the superadmin account +::: tip Setup command +Although this page is listed under Generators, `modularity:create:superadmin` lives in `Console/Setup/` and is typically run as part of the initial [Setup](/guide/console/setup/overview) workflow. +::: + ## Command Information - **Signature:** `modularity:create:superadmin [-d|--default] [-T|--addTranslation] [-M|--addMedia] [-F|--addFile] [-P|--addPosition] [-S|--addSlug] [--addPrice] [-A|--addAuthorized] [-FP|--addFilepond] [--addUuid] [-SS|--addSnapshot] [--] [<email> [<password>]]` -- **Category:** Generators +- **Category:** Setup / Generators ## Examples diff --git a/docs/src/pages/guide/console/generators/create-test-laravel.md b/docs/src/pages/guide/console/generators/create-test-laravel.md new file mode 100644 index 000000000..462dcbc27 --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-test-laravel.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 9 +sidebarTitle: Create Test Laravel +--- + +# Create Test Laravel + +> Generate a PHPUnit feature or unit test stub inside a module. + +## Command Information + +- **Signature:** `modularity:make:laravel:test {module} {test} [--unit]` +- **Aliases:** `modularity:create:laravel:test` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name the test belongs to (e.g. `Blog`) | +| `test` | Yes | The test class name (e.g. `PostCreationTest`) | + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--unit` | `false` | Generate a unit test instead of a feature test | + +## Examples + +```bash +# Generate a feature test +php artisan modularity:make:laravel:test Blog PostCreationTest + +# Generate a unit test +php artisan modularity:make:laravel:test Blog PostSlugTest --unit +``` + +## Output + +Generates the test file inside the target module: + +``` +Modules/Blog/Tests/Feature/PostCreationTest.php +# or with --unit: +Modules/Blog/Tests/Unit/PostSlugTest.php +``` + +The stub extends `Tests\TestCase` and includes a single placeholder `test_example()` method. + +## Related + +- [create:vue-test](./create-vue-test) — generate a Vitest (Vue) test stub +- [coverage:analyze](/guide/console/coverage/coverage-analyze) — find which module files need tests diff --git a/docs/src/pages/guide/commands/Generators/create-theme.md b/docs/src/pages/guide/console/generators/create-theme.md similarity index 99% rename from docs/src/pages/guide/commands/Generators/create-theme.md rename to docs/src/pages/guide/console/generators/create-theme.md index c77e1a7ad..1a09bf4bc 100644 --- a/docs/src/pages/guide/commands/Generators/create-theme.md +++ b/docs/src/pages/guide/console/generators/create-theme.md @@ -1,4 +1,4 @@ -# `Make Theme Folder` +# Create Theme > Create custom theme folder. diff --git a/docs/src/pages/guide/console/generators/create-vue-input.md b/docs/src/pages/guide/console/generators/create-vue-input.md new file mode 100644 index 000000000..70547387b --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-vue-input.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 11 +sidebarTitle: Create Vue Input +--- + +# Create Vue Input + +> Generate a Vue input component stub in the Modularous vendor inputs directory. + +## Command Information + +- **Signature:** `modularity:make:vue:input {name}` +- **Aliases:** `modularity:create:vue:input`, `mod:c:vue:input` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | Yes | Component name (e.g. `ColorPicker` → generates `ColorPicker.vue` registered as `v-input-color-picker`) | + +## What It Does + +Creates a `.vue` file from the `input-component.vue` stub in: + +``` +vendor/modularity/vue/src/js/components/inputs/{StudlyName}.vue +``` + +The component is registered in the input registry with a kebab-case tag name prefixed `v-input-`. If the file already exists the command skips creation and reports a warning. + +## Examples + +```bash +php artisan modularity:make:vue:input ColorPicker +# → creates ColorPicker.vue, registered as v-input-color-picker + +php artisan mod:c:vue:input RatingStars +``` + +## Related + +- [create:input-hydrate](./create-input-hydrate) — generate the matching server-side Hydrate class +- [Input Registry](/system-reference/frontend/overview#input-registry) — how custom input types are registered diff --git a/docs/src/pages/guide/console/generators/create-vue-test.md b/docs/src/pages/guide/console/generators/create-vue-test.md new file mode 100644 index 000000000..863d9d008 --- /dev/null +++ b/docs/src/pages/guide/console/generators/create-vue-test.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 12 +sidebarTitle: Create Vue Test +--- + +# Create Vue Test + +> Generate a Vitest test stub for a Vue component or feature. + +## Command Information + +- **Signature:** `modularity:make:vue:test [name?] [type?] [--importDir] [--F|force]` +- **Aliases:** `modularity:create:vue:test`, `mod:c:vue:test` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | No | Test name (StudlyCase). Prompted interactively if omitted | +| `type` | No | Test type (e.g. `component`, `composable`). Prompted via select if omitted | + +## Options + +| Option | Description | +|--------|-------------| +| `--importDir` | Set a subfolder used as the import base path in the generated test | +| `--force` / `-F` | Overwrite the test file if it already exists | + +## What It Does + +Delegates to `VueTestGenerator`, which resolves the test type and writes a Vitest stub. Available types are determined by the generator's `getTypes()` method. If `name` or `type` are not provided the command prompts interactively. + +## Examples + +```bash +# Fully interactive +php artisan modularity:make:vue:test + +# Provide all arguments +php artisan modularity:make:vue:test ProductCard component + +# Force overwrite +php artisan modularity:make:vue:test ProductCard component --force +``` + +## Related + +- [create:test-laravel](./create-test-laravel) — generate a PHPUnit backend test stub diff --git a/docs/src/pages/guide/console/generators/make-controller-api.md b/docs/src/pages/guide/console/generators/make-controller-api.md new file mode 100644 index 000000000..e0e00c534 --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-controller-api.md @@ -0,0 +1,38 @@ +--- +sidebarPos: 14 +sidebarTitle: Make Controller API +--- + +# Make Controller API + +> Generate an API controller with repository injection for a module. + +## Command Information + +- **Signature:** `modularity:make:controller:api {module} {name}` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module the controller belongs to (e.g. `Blog`) | +| `name` | Yes | Controller class name (e.g. `Post` → generates `PostApiController`) | + +## What It Does + +Creates an API controller stub that returns JSON responses. Use this when you need a dedicated REST API endpoint for a module resource, separate from the admin panel controller. + +## Examples + +```bash +php artisan modularity:make:controller:api Blog Post +# → Modules/Blog/Http/Controllers/PostApiController.php + +php artisan modularity:make:controller:api Shop Product +``` + +## Related + +- [make:controller](./make-controller) — standard admin CRUD controller +- [make:controller:front](./make-controller-front) — Inertia frontend controller diff --git a/docs/src/pages/guide/console/generators/make-controller-front.md b/docs/src/pages/guide/console/generators/make-controller-front.md new file mode 100644 index 000000000..79e31b38d --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-controller-front.md @@ -0,0 +1,38 @@ +--- +sidebarPos: 15 +sidebarTitle: Make Controller Front +--- + +# Make Controller Front + +> Generate an Inertia frontend controller for a module. + +## Command Information + +- **Signature:** `modularity:make:controller:front {module} {name}` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module the controller belongs to (e.g. `Blog`) | +| `name` | Yes | Controller class name (e.g. `Post` → generates `PostFrontController`) | + +## What It Does + +Creates a frontend controller stub that uses `Inertia::render()` to return Vue page components to the browser. Use this for public-facing routes that need server-side data passed to Vue via Inertia props. + +## Examples + +```bash +php artisan modularity:make:controller:front Blog Post +# → Modules/Blog/Http/Controllers/PostFrontController.php + +php artisan modularity:make:controller:front Shop Product +``` + +## Related + +- [make:controller](./make-controller) — standard admin CRUD controller +- [make:controller:api](./make-controller-api) — API-only controller diff --git a/docs/src/pages/guide/console/generators/make-controller.md b/docs/src/pages/guide/console/generators/make-controller.md new file mode 100644 index 000000000..91a40ee92 --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-controller.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 13 +sidebarTitle: Make Controller +--- + +# Make Controller + +> Generate a standard CRUD controller with repository injection for a module. + +## Command Information + +- **Signature:** `modularity:make:controller {module} {name}` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module the controller belongs to (e.g. `Blog`) | +| `name` | Yes | Controller class name (e.g. `Post` → generates `PostController`) | + +## What It Does + +Creates a `{Name}Controller.php` stub inside the module's `Http/Controllers/` directory. The generated controller extends Modularous base controller and is wired to accept the matching repository via constructor injection. + +## Examples + +```bash +php artisan modularity:make:controller Blog Post +# → Modules/Blog/Http/Controllers/PostController.php + +php artisan modularity:make:controller Shop Product +``` + +## Related + +- [make:controller:api](./make-controller-api) — API-only controller variant +- [make:controller:front](./make-controller-front) — Inertia frontend controller variant +- [make:repository](./make-repository) — generate the matching repository diff --git a/docs/src/pages/guide/console/generators/make-event.md b/docs/src/pages/guide/console/generators/make-event.md new file mode 100644 index 000000000..d8a852d5e --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-event.md @@ -0,0 +1,196 @@ +# Make Event + +> Create a Laravel Event. + +## Command Information + +- **Signature:** `modularity:make:event <name> [<module>] [--self] [-f|--force] [--should-broadcast] [--should-broadcast-now] [--should-dispatch-after-commit]` +- **Category:** Generators + +## Examples + +### Create an event in the default app path + +```bash +php artisan modularity:make:event OrderShipped +``` + +### Create an event inside a module + +```bash +php artisan modularity:make:event OrderShipped Shop +``` + +### Create a broadcastable event + +```bash +php artisan modularity:make:event OrderShipped --should-broadcast +``` + +### Create an event that broadcasts immediately (no queue) + +```bash +php artisan modularity:make:event OrderShipped --should-broadcast-now +``` + +### Create an event that dispatches after database commit + +```bash +php artisan modularity:make:event OrderShipped --should-dispatch-after-commit +``` + +`modularity:make:event` +----------------------- + +Scaffolds a new Laravel Event class. When abstract event classes are found in `app/Events/`, the package's own `src/Events/`, or any module's Events directory, an **interactive prompt** lets you optionally extend one of them. + +If `--should-broadcast` or `--should-broadcast-now` is passed, additional prompts collect the queue connection name, queue name, and broadcast channel type and name. + +Output path resolution: +- No `module` and no `--self` → `app/Events/` +- `module` given → module's Events directory +- `--self` flag → `packages/modularous/src/Events/` (dev only, not allowed in production) + +### Usage + +* `modularity:make:event <name> [<module>] [--self] [-f|--force] [--should-broadcast] [--should-broadcast-now] [--should-dispatch-after-commit]` + +### Arguments + +#### `name` + +The name of the event class. + +* Is required: yes +* Is array: no +* Default: `NULL` + +#### `module` + +The module to create the event in. If omitted, the event is created in `app/Events/`. + +* Is required: no +* Is array: no +* Default: `NULL` + +### Options + +#### `--self` + +Create the event inside the Modularous package source (`src/Events/`). Dev use only. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--force|-f` + +Overwrite the file if it already exists. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--should-broadcast` + +Implement `ShouldBroadcast` — event is dispatched to a queue before broadcasting. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--should-broadcast-now` + +Implement `ShouldBroadcastNow` — event broadcasts synchronously without a queue. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--should-dispatch-after-commit` + +Implement `ShouldDispatchAfterCommit` — event is dispatched only after the current database transaction commits. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--help|-h` + +Display help for the given command. When no command is given display help for the list command + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--quiet|-q` + +Do not output any message + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--verbose|-v|-vv|-vvv` + +Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--version|-V` + +Display this application version + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--ansi|--no-ansi` + +Force (or disable --no-ansi) ANSI output + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: yes +* Default: `NULL` + +#### `--no-interaction|-n` + +Do not ask any interactive question + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--env` + +The environment the command should run under + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` diff --git a/docs/src/pages/guide/console/generators/make-horizon-supervisor.md b/docs/src/pages/guide/console/generators/make-horizon-supervisor.md new file mode 100644 index 000000000..f85f4e7f1 --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-horizon-supervisor.md @@ -0,0 +1,120 @@ +# Make Horizon Supervisor + +> Create a Horizon Supervisor configuration file. + +## Command Information + +- **Signature:** `modularity:make:horizon:supervisor` +- **Alias:** `modularity:create:horizon:supervisor` +- **Category:** Generators + +## Examples + +### Run the interactive setup + +```bash +php artisan modularity:make:horizon:supervisor +``` + +`modularity:make:horizon:supervisor` +------------------------------------- + +Interactively generates a Supervisor `.conf` file for Laravel Horizon. The command: + +1. Detects the operating system and checks if `supervisord` is installed. If not, it prints the appropriate install command for the detected platform (Homebrew on macOS, apt/yum on Linux). +2. Finds a writable Supervisor configuration directory (`/etc/supervisor/conf.d`, `/etc/supervisord.d`, `/opt/homebrew/etc/supervisor.d`, etc.). +3. Prompts for the configuration details listed below. +4. Optionally prints the generated config content for review. +5. Writes the `.conf` file to the detected Supervisor directory. + +**Interactive prompts:** + +| Prompt | Default | +|--------|---------| +| Supervisor config name | `b2press-app` | +| PHP binary path | `php` | +| Application path | Current project root | +| Artisan command to run | `artisan horizon` | +| System user to run as | `root` | +| Log file name | `horizon` | + +After the file is written, the command prints the `supervisorctl reread`, `update`, and `start` commands needed to activate the new configuration. + +> **Note:** This command is not supported on Windows. + +### Usage + +* `modularity:make:horizon:supervisor` +* `modularity:create:horizon:supervisor` + +### Options + +#### `--help|-h` + +Display help for the given command. When no command is given display help for the list command + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--quiet|-q` + +Do not output any message + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--verbose|-v|-vv|-vvv` + +Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--version|-V` + +Display this application version + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--ansi|--no-ansi` + +Force (or disable --no-ansi) ANSI output + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: yes +* Default: `NULL` + +#### `--no-interaction|-n` + +Do not ask any interactive question + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--env` + +The environment the command should run under + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` diff --git a/docs/src/pages/guide/console/generators/make-listener.md b/docs/src/pages/guide/console/generators/make-listener.md new file mode 100644 index 000000000..5da8b9908 --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-listener.md @@ -0,0 +1,180 @@ +# Make Listener + +> Create a Laravel Listener. + +## Command Information + +- **Signature:** `modularity:make:listener <name> [<module>] [--self] [-f|--force] [--should-queue] [--should-handle-events-after-commit]` +- **Category:** Generators + +## Examples + +### Create a listener in the default app path + +```bash +php artisan modularity:make:listener SendOrderConfirmation +``` + +### Create a listener inside a module + +```bash +php artisan modularity:make:listener SendOrderConfirmation Shop +``` + +### Create a queued listener + +```bash +php artisan modularity:make:listener SendOrderConfirmation --should-queue +``` + +### Create a listener that handles events after database commit + +```bash +php artisan modularity:make:listener SendOrderConfirmation --should-handle-events-after-commit +``` + +`modularity:make:listener` +-------------------------- + +Scaffolds a new Laravel Listener class. An **interactive prompt** lets you optionally bind the listener to an existing event class discovered from `app/Events/`, the package's own events, or any module's Events directory. + +If `--should-queue` is passed, additional prompts collect the queue connection, queue name, delay in seconds, and max retry attempts, and a `shouldQueue()` method is generated. + +Output path resolution: +- No `module` and no `--self` → `app/Listeners/` +- `module` given → module's Listeners directory +- `--self` flag → `packages/modularous/src/Listeners/` (dev only) + +### Usage + +* `modularity:make:listener <name> [<module>] [--self] [-f|--force] [--should-queue] [--should-handle-events-after-commit]` + +### Arguments + +#### `name` + +The name of the listener class. + +* Is required: yes +* Is array: no +* Default: `NULL` + +#### `module` + +The module to create the listener in. If omitted, the listener is created in `app/Listeners/`. + +* Is required: no +* Is array: no +* Default: `NULL` + +### Options + +#### `--self` + +Create the listener inside the Modularous package source (`src/Listeners/`). Dev use only. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--force|-f` + +Overwrite the file if it already exists. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--should-queue` + +Implement `ShouldQueue` — listener is processed asynchronously via a queue. Prompts for connection, queue name, delay, and max tries. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--should-handle-events-after-commit` + +Implement `ShouldHandleEventsAfterCommit` — listener only runs after the current database transaction commits. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--help|-h` + +Display help for the given command. When no command is given display help for the list command + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--quiet|-q` + +Do not output any message + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--verbose|-v|-vv|-vvv` + +Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--version|-V` + +Display this application version + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--ansi|--no-ansi` + +Force (or disable --no-ansi) ANSI output + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: yes +* Default: `NULL` + +#### `--no-interaction|-n` + +Do not ask any interactive question + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--env` + +The environment the command should run under + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` diff --git a/docs/src/pages/guide/commands/Generators/make-migration.md b/docs/src/pages/guide/console/generators/make-migration.md similarity index 99% rename from docs/src/pages/guide/commands/Generators/make-migration.md rename to docs/src/pages/guide/console/generators/make-migration.md index 810e2801e..860a7662f 100644 --- a/docs/src/pages/guide/commands/Generators/make-migration.md +++ b/docs/src/pages/guide/console/generators/make-migration.md @@ -1,4 +1,4 @@ -# `Make Migration` +# Make Migration > Create a new migration for the specified module. diff --git a/docs/src/pages/guide/commands/Generators/make-model.md b/docs/src/pages/guide/console/generators/make-model.md similarity index 99% rename from docs/src/pages/guide/commands/Generators/make-model.md rename to docs/src/pages/guide/console/generators/make-model.md index 91c0995a8..b9167f9e2 100644 --- a/docs/src/pages/guide/commands/Generators/make-model.md +++ b/docs/src/pages/guide/console/generators/make-model.md @@ -1,4 +1,4 @@ -# `Make Model` +# Make Model > Create a new model for the specified module. diff --git a/docs/src/pages/guide/commands/Generators/make-module.md b/docs/src/pages/guide/console/generators/make-module.md similarity index 99% rename from docs/src/pages/guide/commands/Generators/make-module.md rename to docs/src/pages/guide/console/generators/make-module.md index 15560c569..008d0c4c0 100644 --- a/docs/src/pages/guide/commands/Generators/make-module.md +++ b/docs/src/pages/guide/console/generators/make-module.md @@ -1,4 +1,4 @@ -# `Make Module` +# Make Module > Create a module diff --git a/docs/src/pages/guide/console/generators/make-operation.md b/docs/src/pages/guide/console/generators/make-operation.md new file mode 100644 index 000000000..d134ccce9 --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-operation.md @@ -0,0 +1,181 @@ +# Make Operation + +> Create a one-time operation file with the Modularous tag. + +## Command Information + +- **Signature:** `modularity:make:operation <name> [--self] [--path[=PATH]] [-t|--tag[=TAG]] [--async] [--queue[=QUEUE]]` +- **Aliases:** `modularity:operations:make`, `modularity:create:operation`, `mod:c:operation` +- **Category:** Generators + +> **Requires:** [`timokoerber/laravel-one-time-operations`](https://github.com/TimoKoerber/laravel-one-time-operations) package. + +## Examples + +### Create an operation in the default operations directory + +```bash +php artisan modularity:make:operation SeedNewPermissions +``` + +### Create an operation with a custom tag + +```bash +php artisan modularity:make:operation SeedNewPermissions --tag=permissions +``` + +### Create an asynchronous operation on a specific queue + +```bash +php artisan modularity:make:operation SeedNewPermissions --async --queue=operations +``` + +### Create an operation at a custom path + +```bash +php artisan modularity:make:operation SeedNewPermissions --path=database/operations +``` + +`modularity:make:operation` +--------------------------- + +Scaffolds a new one-time operation file using the `timokoerber/laravel-one-time-operations` package. The generated filename includes a timestamp prefix and an `_operation` suffix (e.g. `2026_04_10_120000_seed_new_permissions_operation.php`). The default output directory is read from `config('one-time-operations.directory')`, typically `operations/`. + +### Usage + +* `modularity:make:operation <name> [--self] [--path[=PATH]] [-t|--tag[=TAG]] [--async] [--queue[=QUEUE]]` +* `modularity:operations:make <name>` +* `modularity:create:operation <name>` +* `mod:c:operation <name>` + +### Arguments + +#### `name` + +The name of the operation. + +* Is required: yes +* Is array: no +* Default: `NULL` + +### Options + +#### `--self` + +Create the operation inside the Modularous package source (`operations/`). Tags it as `modularity` automatically. Dev use only. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--path` + +Custom output directory for the operation file. Relative paths are resolved from the project root. + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` + +#### `--tag|-t` + +Tag to assign to the operation. Used by the one-time-operations runner to filter which operations to process. + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` + +#### `--async` + +Generate the operation as asynchronous (processed via a queue job). + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--queue` + +The queue to dispatch the asynchronous operation to. + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `default` + +#### `--help|-h` + +Display help for the given command. When no command is given display help for the list command + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--quiet|-q` + +Do not output any message + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--verbose|-v|-vv|-vvv` + +Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--version|-V` + +Display this application version + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--ansi|--no-ansi` + +Force (or disable --no-ansi) ANSI output + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: yes +* Default: `NULL` + +#### `--no-interaction|-n` + +Do not ask any interactive question + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--env` + +The environment the command should run under + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` diff --git a/docs/src/pages/guide/commands/Generators/make-repository.md b/docs/src/pages/guide/console/generators/make-repository.md similarity index 99% rename from docs/src/pages/guide/commands/Generators/make-repository.md rename to docs/src/pages/guide/console/generators/make-repository.md index 28a6d2c81..e675095f8 100644 --- a/docs/src/pages/guide/commands/Generators/make-repository.md +++ b/docs/src/pages/guide/console/generators/make-repository.md @@ -1,4 +1,4 @@ -# `Make Repository` +# Make Repository > Create a new repository class for the specified module. diff --git a/docs/src/pages/guide/console/generators/make-request.md b/docs/src/pages/guide/console/generators/make-request.md new file mode 100644 index 000000000..2728bf281 --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-request.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 24 +sidebarTitle: Make Request +--- + +# Make Request + +> Generate a Form Request class (store/update validation) for a module. + +## Command Information + +- **Signature:** `modularity:make:request {module} {request} [--rules=]` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module the request belongs to (e.g. `Blog`) | +| `request` | Yes | Request class name (e.g. `StorePost` → generates `StorePostRequest`) | + +## Options + +| Option | Description | +|--------|-------------| +| `--rules=` | Seed the request with validation rules in `field=rule1\|rule2&field2=rule` format (parsed by `ValidatorParser`) | + +## Examples + +```bash +# Generate an empty StorePostRequest +php artisan modularity:make:request Blog StorePost + +# Generate with seeded validation rules +php artisan modularity:make:request Blog StorePost --rules="title=required|string|max:255&body=nullable|string" +``` + +## Output + +``` +Modules/Blog/Http/Requests/StorePostRequest.php +``` + +## Related + +- [make:controller](./make-controller) — the controller that uses the request +- [Decomposers\ValidatorParser](/system-reference/backend/support/decomposers#validatorparser) — parses the `--rules` string diff --git a/docs/src/pages/guide/commands/Generators/make-route.md b/docs/src/pages/guide/console/generators/make-route.md similarity index 99% rename from docs/src/pages/guide/commands/Generators/make-route.md rename to docs/src/pages/guide/console/generators/make-route.md index 77249ba08..5c30de1a0 100644 --- a/docs/src/pages/guide/commands/Generators/make-route.md +++ b/docs/src/pages/guide/console/generators/make-route.md @@ -1,4 +1,4 @@ -# `Make Route` +# Make Route > Create files for routes. diff --git a/docs/src/pages/guide/console/generators/make-stubs.md b/docs/src/pages/guide/console/generators/make-stubs.md new file mode 100644 index 000000000..9086512cd --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-stubs.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 26 +sidebarTitle: Make Stubs +--- + +# Make Stubs + +> Generate or regenerate the stub files (views, JS, config) for a module route. + +## Command Information + +- **Signature:** `modularity:make:stubs {module} {route} [--only=] [--except=] [--force] [--fix]` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name (e.g. `Blog`) | +| `route` | Yes | The route name within the module (e.g. `posts`) | + +## Options + +| Option | Description | +|--------|-------------| +| `--only=` | Comma-separated list of stub types to generate (e.g. `view,js`) | +| `--except=` | Comma-separated list of stub types to skip | +| `--force` | Overwrite files that already exist | +| `--fix` | Fix model config errors instead of generating fresh files | + +## What It Does + +Delegates to `StubsGenerator`, which writes the Blade views, Vue page components, and supporting config files for the specified module/route combination. Use `--only` or `--except` to regenerate a subset of stubs after manual edits to avoid overwriting customised files. + +## Examples + +```bash +# Generate all stubs for Blog/posts +php artisan modularity:make:stubs Blog posts + +# Regenerate only the view stubs, overwriting existing +php artisan modularity:make:stubs Blog posts --only=view --force + +# Regenerate everything except the JS stubs +php artisan modularity:make:stubs Blog posts --except=js + +# Fix config errors without regenerating +php artisan modularity:make:stubs Blog posts --fix +``` + +## Related + +- [make:module](./make-module) — full module scaffold (calls this internally) +- [make:route](./make-route) — add a route entry diff --git a/docs/src/pages/guide/console/generators/make-theme.md b/docs/src/pages/guide/console/generators/make-theme.md new file mode 100644 index 000000000..11f226fda --- /dev/null +++ b/docs/src/pages/guide/console/generators/make-theme.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 27 +sidebarTitle: Make Theme +--- + +# Make Theme + +> Generalise (export) a local custom theme into the Modularous theme index so it can be referenced globally. + +## Command Information + +- **Signature:** `modularity:make:theme {name} [--force]` +- **Category:** Generators + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | Yes | Name of the custom theme to generalise (must already exist under `resources/vendor/modularity/themes/{name}/`) | + +## Options + +| Option | Description | +|--------|-------------| +| `--force` | Overwrite existing theme export entries | + +## What It Does + +Reads the theme's JS entry point at `resources/vendor/modularity/themes/{name}/{name}.js` and appends a named export to the Modularous themes index file: + +```js +// vue/src/js/config/themes/index.js +export { default as corporate } from './corporate' +``` + +This makes the theme available to Modularous theme system without manual edits to the index. Run after creating a new theme folder with [create:theme](./create-theme). + +## Examples + +```bash +php artisan modularity:make:theme corporate +php artisan modularity:make:theme dark-mode --force +``` + +## Related + +- [create:theme](./create-theme) — create the theme folder structure first diff --git a/docs/src/pages/guide/console/generators/overview.md b/docs/src/pages/guide/console/generators/overview.md new file mode 100644 index 000000000..df7145895 --- /dev/null +++ b/docs/src/pages/guide/console/generators/overview.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 10 +sidebarTitle: Overview +sidebarGroupTitle: Generators +--- + +# Generator Commands + +Scaffold every layer of a Modularous module — from the module skeleton down to individual controllers, models, migrations, events, Vue inputs, and tests. + +## Module Scaffold + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:module](./make-module) | `modularity:make:module` | Scaffold a complete module (model, controller, repository, migration, routes, hydrate, Vue input) | +| [make:route](./make-route) | `modularity:make:route` | Add a new route entry to an existing module | + +## Models & Data + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:model](./make-model) | `modularity:make:model` | Generate an Eloquent model with optional traits, relations, and migration | +| [make:migration](./make-migration) | `modularity:make:migration` | Generate a module migration file | +| [make:repository](./make-repository) | `modularity:make:repository` | Generate a repository class for a module model | +| [create:model-trait](./create-model-trait) | `modularity:create:model:trait` | Create a reusable model trait | +| [create:repository-trait](./create-repository-trait) | `modularity:create:repository:trait` | Create a reusable repository trait | + +## Controllers & Requests + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:controller](./make-controller) | `modularity:make:controller` | Generate a standard CRUD controller | +| [make:controller-api](./make-controller-api) | `modularity:make:controller:api` | Generate an API controller | +| [make:controller-front](./make-controller-front) | `modularity:make:controller:front` | Generate a frontend (Inertia) controller | +| [make:request](./make-request) | `modularity:make:request` | Generate a Form Request class | + +## Events & Listeners + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:event](./make-event) | `modularity:make:event` | Generate a module event class | +| [make:listener](./make-listener) | `modularity:make:listener` | Generate a module listener class | +| [make:operation](./make-operation) | `modularity:make:operation` | Generate an Operation class for the operations pipeline | + +## Frontend + +| Command | Signature | Description | +|---------|-----------|-------------| +| [create:input-hydrate](./create-input-hydrate) | `modularity:create:input:hydrate` | Generate a Hydrate class for a module | +| [create:vue-input](./create-vue-input) | `modularity:create:vue:input` | Generate a Vue input component stub | + +## Themes & UI + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:theme](./make-theme) | `modularity:make:theme` | Generalise a theme | +| [create:theme](./create-theme) | `modularity:make:theme:folder` | Create a custom theme folder (alias: `modularity:create:theme`) | +| [create:feature](./create-feature) | `modularity:create:feature` | Create a frontend feature module | + +## Auth & Users + +| Command | Signature | Description | +|---------|-----------|-------------| +| [create:superadmin](./create-superadmin) | `modularity:create:superadmin` | Create the initial superadmin account | +| [create:route-permissions](./create-route-permissions) | `modularity:create:route:permissions` | Generate Spatie permission records for module routes | +| [make:horizon-supervisor](./make-horizon-supervisor) | `modularity:make:horizon:supervisor` | Generate a Horizon supervisor config | + +## Tests & Stubs + +| Command | Signature | Description | +|---------|-----------|-------------| +| [create:test-laravel](./create-test-laravel) | `modularity:create:test:laravel` | Generate a PHPUnit test stub | +| [create:vue-test](./create-vue-test) | `modularity:create:vue:test` | Generate a Vitest test stub | +| [make:stubs](./make-stubs) | `modularity:make:stubs` | Publish/overwrite generator stub files | +| [make:command](./create-command) | `modularity:make:command` | Generate a new Artisan command class | + +> Documentation generation commands live in their own category: see [Docs](../docs/overview). diff --git a/docs/src/pages/guide/console/get-version.md b/docs/src/pages/guide/console/get-version.md new file mode 100644 index 000000000..def55e3b3 --- /dev/null +++ b/docs/src/pages/guide/console/get-version.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 2 +sidebarTitle: Get Version +--- + +# Get Version + +> Print the installed version of any Composer package. + +## Command Information + +- **Signature:** `modularity:get:version [--p|package=]` +- **Alias:** `mod:g:ver` +- **Category:** Module + +## Options + +| Option | Description | +|--------|-------------| +| `--package=` / `-p` | The Composer package name to look up (e.g. `unusualify/modularity`) | + +## What It Does + +Calls `get_package_version($package)` and prints the result. Useful for quickly confirming which version of Modularous or any dependency is active without opening `composer.lock`. + +## Examples + +```bash +php artisan modularity:get:version --package=unusualify/modularity +php artisan mod:g:ver -p laravel/framework +``` + +## Related + +- [refresh](./refresh) — republish frontend assets after an upgrade diff --git a/docs/src/pages/guide/console/make/command.md b/docs/src/pages/guide/console/make/command.md new file mode 100644 index 000000000..9f38df9a1 --- /dev/null +++ b/docs/src/pages/guide/console/make/command.md @@ -0,0 +1,74 @@ +--- +sidebarPos: 15 +sidebarTitle: make:command +--- + +# make:command + +> Scaffold a new Artisan command class + +**Signature**: `modularity:make:command` + +**Aliases**: `modularity:create:command`, `mod:c:cmd` + +**Category**: Make + +--- + +## Description + +Creates a new Artisan command class inside `src/Console/` of the Modularous vendor path. The signature is automatically prefixed with `modularity:`, tab and newline escape sequences (`\t`, `\n`) in the signature string are converted to real whitespace. + +--- + +## Usage + +``` +modularity:make:command [options] <name> <signature> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Class name (studly-cased, `Command` suffix added automatically) | +| `signature` | yes | Artisan signature string (without `modularity:` prefix) | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--description=` | `-d` | Command description string | + +--- + +## Examples + +### Minimal command + +```bash +php artisan modularity:make:command SyncThemes sync:themes +# → src/Console/SyncThemesCommand.php with signature: modularity:sync:themes +``` + +### Command with arguments and description + +```bash +php artisan modularity:make:command ImportData \ + "import:data {source : The data source path}" \ + --description="Import data from a source file" +``` + +--- + +## Output + +`src/Console/{Name}Command.php` + +**Stub**: `scaffold/command.stub` + +--- + +## See also + +- [System Reference](/system-reference/backend/console/make#makeconsolecommand) diff --git a/docs/src/pages/guide/console/make/controller-api.md b/docs/src/pages/guide/console/make/controller-api.md new file mode 100644 index 000000000..7588b56e1 --- /dev/null +++ b/docs/src/pages/guide/console/make/controller-api.md @@ -0,0 +1,58 @@ +--- +sidebarPos: 8 +sidebarTitle: make:controller:api +--- + +# make:controller:api + +> Create a REST API controller for a module + +**Signature**: `modularity:make:controller:api` + +**Category**: Make + +--- + +## Description + +Generates an API controller in the module's `Http/Controllers/API/` path. The stub is wired up with the module namespace, studly and lower-case module name, and the controller class name. + +--- + +## Usage + +``` +modularity:make:controller:api <module> <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | Target module | +| `name` | yes | Controller class name (suffix `Controller` added automatically if absent) | + +--- + +## Examples + +```bash +php artisan modularity:make:controller:api Blog Post +# → Blog/Http/Controllers/API/PostController.php +``` + +--- + +## Output + +`{Module}/Http/Controllers/API/{Name}Controller.php` + +**Stub**: `route-controller-api.stub` + +--- + +## See also + +- [make:controller](./controller) — admin-panel variant +- [make:controller:front](./controller-front) — front-end variant +- [System Reference](/system-reference/backend/console/make#makecontrollerapicommand) diff --git a/docs/src/pages/guide/console/make/controller-front.md b/docs/src/pages/guide/console/make/controller-front.md new file mode 100644 index 000000000..fa6217ee2 --- /dev/null +++ b/docs/src/pages/guide/console/make/controller-front.md @@ -0,0 +1,58 @@ +--- +sidebarPos: 9 +sidebarTitle: make:controller:front +--- + +# make:controller:front + +> Create a front-end (public-facing) controller for a module + +**Signature**: `modularity:make:controller:front` + +**Category**: Make + +--- + +## Description + +Generates a front-end controller in the module's `Http/Controllers/Front/` path. Suitable for public-facing pages, Inertia.js views, or SSR routes. The stub receives the module name, studly/lower variants, and route name. + +--- + +## Usage + +``` +modularity:make:controller:front <module> <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | Target module | +| `name` | yes | Controller class name | + +--- + +## Examples + +```bash +php artisan modularity:make:controller:front Blog Post +# → Blog/Http/Controllers/Front/PostController.php +``` + +--- + +## Output + +`{Module}/Http/Controllers/Front/{Name}Controller.php` + +**Stub**: `route-controller-front.stub` + +--- + +## See also + +- [make:controller](./controller) — admin-panel variant +- [make:controller:api](./controller-api) — API variant +- [System Reference](/system-reference/backend/console/make#makecontrollerfrontcommand) diff --git a/docs/src/pages/guide/console/make/controller.md b/docs/src/pages/guide/console/make/controller.md new file mode 100644 index 000000000..67182fbb4 --- /dev/null +++ b/docs/src/pages/guide/console/make/controller.md @@ -0,0 +1,64 @@ +--- +sidebarPos: 7 +sidebarTitle: make:controller +--- + +# make:controller + +> Create an admin-panel CRUD controller for a module + +**Signature**: `modularity:make:controller` + +**Category**: Make + +--- + +## Description + +Generates a controller that extends the configured `base_controller` (default: Modularous admin base). The class receives the module's namespace, route name, and base controller reference as stub variables. + +--- + +## Usage + +``` +modularity:make:controller <module> <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | Target module | +| `name` | yes | Controller class name (suffix `Controller` added automatically if absent) | + +--- + +## Examples + +```bash +php artisan modularity:make:controller Blog Post +# → Blog/Http/Controllers/PostController.php +``` + +```bash +php artisan modularity:make:controller Blog PostController +# → Blog/Http/Controllers/PostController.php (same result) +``` + +--- + +## Output + +`{Module}/Http/Controllers/{Name}Controller.php` + +**Stub**: `route-controller.stub` + +--- + +## See also + +- [make:controller:api](./controller-api) — REST API variant +- [make:controller:front](./controller-front) — front-end variant +- [make:route](./route) — generates controller as part of a full route scaffold +- [System Reference](/system-reference/backend/console/make#makecontrollercommand) diff --git a/docs/src/pages/guide/console/make/event.md b/docs/src/pages/guide/console/make/event.md new file mode 100644 index 000000000..3d9a38979 --- /dev/null +++ b/docs/src/pages/guide/console/make/event.md @@ -0,0 +1,93 @@ +--- +sidebarPos: 11 +sidebarTitle: make:event +--- + +# make:event + +> Create a Laravel Event class + +**Signature**: `modularity:make:event` + +**Category**: Make + +--- + +## Description + +Interactive wizard that generates a Laravel Event class. It scans all abstract event classes found in `app/Events`, the Modularous vendor `src/Events/`, and every loaded module, then lets you choose a base class to extend. Broadcasting options prompt for channel type, queue connection, and channel name. + +--- + +## Usage + +``` +modularity:make:event [options] <name> [<module>] +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Event class name | +| `module` | no | Target module; omit to create in `app/Events/` | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--self` | | Write to Modularous vendor path (`src/Events/`) | +| `--force` | `-f` | Overwrite existing file | +| `--should-broadcast` | | Implement `ShouldBroadcast`; prompts for channel, queue name | +| `--should-broadcast-now` | | Implement `ShouldBroadcastNow`; prompts for channel | +| `--should-dispatch-after-commit` | | Implement `ShouldDispatchAfterCommit` | + +--- + +## Examples + +### Module event + +```bash +php artisan modularity:make:event PostPublished Blog +``` + +### Broadcastable event + +```bash +php artisan modularity:make:event PostPublished Blog --should-broadcast +# Prompts: abstract base class, channel type (Channel/Private/Presence), queue, channel name +``` + +### App-level deferred event + +```bash +php artisan modularity:make:event OrderShipped --should-dispatch-after-commit +``` + +--- + +## Interactive prompts + +1. Select abstract base class (if any exist) — or choose "No" +2. If `--should-broadcast` or `--should-broadcast-now`: select queue connection, enter queue name +3. If broadcasting: select channel type and enter channel name + +--- + +## Output + +| Condition | Path | +|-----------|------| +| Module provided | `{Module}/Events/{Name}.php` | +| No module | `app/Events/{Name}.php` | +| `--self` | `src/Events/{Name}.php` (vendor) | + +**Stub**: `event.stub` + +--- + +## See also + +- [make:listener](./listener) — create the matching listener +- [System Reference](/system-reference/backend/console/make#makeeventcommand) diff --git a/docs/src/pages/guide/console/make/feature.md b/docs/src/pages/guide/console/make/feature.md new file mode 100644 index 000000000..dfc291618 --- /dev/null +++ b/docs/src/pages/guide/console/make/feature.md @@ -0,0 +1,108 @@ +--- +sidebarPos: 26 +sidebarTitle: make:feature +--- + +# make:feature + +> Interactive wizard for scaffolding a full Modularous feature bundle + +**Signature**: `modularity:make:feature` + +**Aliases**: `modularity:create:feature`, `mod:c:feature` + +**Category**: Make + +--- + +## Description + +`make:feature` is a composite wizard that orchestrates multiple other `make:*` commands to scaffold every layer of a new Modularous feature — from backend traits and models to Vue input components and their tests. Each step is optional; you confirm or skip each component interactively. + +--- + +## Usage + +``` +modularity:make:feature [<name>] +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | no | Feature name (prompted if omitted) | + +--- + +## Interactive steps + +| Prompt | If yes — calls | +|--------|---------------| +| Create a **repository trait**? | [`make:repository:trait`](./repository-trait) | +| Create a **model trait**? | [`make:model:trait`](./model-trait) | +| Create a **model and migration**? | [`make:model --self --no-defaults`](./model) + [`make:migration --self --no-defaults`](./migration) | +| Create a **Vue input component**? | [`make:vue:input`](./vue-input) | +| → Create a **Vue component test**? | [`make:vue:test`](./vue-test) with `type=component` | +| → Create an **Input Hydrate class**? | [`make:input:hydrate`](./input-hydrate) | + +--- + +## Examples + +### Fully interactive + +```bash +php artisan modularity:make:feature +# Prompts: feature name, then each component +``` + +### With name pre-set + +```bash +php artisan modularity:make:feature ColorPicker +# Skips the name prompt; still asks about each component +``` + +--- + +## Typical session + +``` +What is the name of the feature? > ColorPicker + +Do you want to create a repository trait for this feature? > yes + → src/Repositories/Traits/ColorPickerTrait.php created + +Do you want to create a model trait for this feature? > no + +Do you want to create a model and migration for this feature? > no + +Do you want to create a vue input component for this feature? > yes + What will be the name of the input component? > ColorPicker + → vue/src/js/components/inputs/ColorPicker.vue created + + Do you want to create a vue component test for this input component? > yes + → test file created + + Do you want to create an input hydrate class for this feature? > yes + → src/Hydrates/Inputs/ColorPickerHydrate.php created + +Feature created successfully +``` + +--- + +## Notes + +- Model and migration created by this wizard use `--self` (vendor path) and `--no-defaults`. +- This command is `$hidden = true` — it does not appear in `php artisan list`. + +--- + +## See also + +- [make:model:trait](./model-trait) — create a trait standalone +- [make:repository:trait](./repository-trait) — create a repository trait standalone +- [make:vue:input](./vue-input) — create a Vue input standalone +- [System Reference](/system-reference/backend/console/make#makefeaturecommand) diff --git a/docs/src/pages/guide/console/make/horizon-supervisor.md b/docs/src/pages/guide/console/make/horizon-supervisor.md new file mode 100644 index 000000000..e2f7b3024 --- /dev/null +++ b/docs/src/pages/guide/console/make/horizon-supervisor.md @@ -0,0 +1,83 @@ +--- +sidebarPos: 14 +sidebarTitle: make:horizon:supervisor +--- + +# make:horizon:supervisor + +> Generate a Supervisor configuration file for Laravel Horizon + +**Signature**: `modularity:make:horizon:supervisor` + +**Alias**: `modularity:create:horizon:supervisor` + +**Category**: Make + +--- + +## Description + +Interactive wizard that creates a Supervisor `.conf` file for running Laravel Horizon as a daemon. Automatically detects the OS (macOS or Linux), locates a writable Supervisor config directory, and checks for existing processes with the same app name before writing. Prints the `supervisorctl` commands needed to activate the new config. + +--- + +## Usage + +``` +modularity:make:horizon:supervisor +``` + +No arguments or options — fully interactive. + +--- + +## Interactive prompts + +| Prompt | Default | Description | +|--------|---------|-------------| +| Config name | `b2press-app` | Used as the supervisor program name prefix | +| PHP binary | `php` | Path to the PHP executable | +| App path | `base_path()` | Absolute path to your Laravel application | +| Command | `artisan horizon` | Artisan command to run (prepended with app path) | +| User | `root` | OS user to run the process as | +| Log file name | `horizon` | Base name for the log file | + +--- + +## After running + +The command prints the three activation commands: + +```bash +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start {programName} +``` + +--- + +## Supervisor config directories checked + +**macOS** (Homebrew): +- `/usr/local/etc/supervisor/conf.d` +- `/usr/local/etc/supervisor.d` +- `/opt/homebrew/etc/supervisor.d` + +**Linux**: +- `/etc/supervisor/conf.d` +- `/etc/supervisord.d` +- `/etc/supervisord/conf.d` + +--- + +## Notes + +- The command will suggest install instructions if Supervisor is not found (`brew install supervisor` or `apt-get install supervisor`). +- Not supported on Windows. +- The generated program name is unique (`{appName}-{uniqid()}-horizon`) to avoid conflicts. + +--- + +## See also + +- [System Reference](/system-reference/backend/console/make#makehorizonsupervisorcommand) diff --git a/docs/src/pages/guide/console/make/input-hydrate.md b/docs/src/pages/guide/console/make/input-hydrate.md new file mode 100644 index 000000000..33648986a --- /dev/null +++ b/docs/src/pages/guide/console/make/input-hydrate.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 22 +sidebarTitle: make:input:hydrate +--- + +# make:input:hydrate + +> Create a PHP Hydrate class for a Vue input component + +**Signature**: `modularity:make:input:hydrate` + +**Aliases**: `modularity:create:input:hydrate`, `mod:c:input:hydrate` + +**Category**: Make + +--- + +## Description + +Creates an Input Hydrate class in `src/Hydrates/Inputs/`. Hydrate classes are responsible for transforming data between the database representation and the format expected by a Vue input component. The command is a no-op if the class already exists (shows a warning instead). + +--- + +## Usage + +``` +modularity:make:input:hydrate <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Input / component name (studly-cased; `Hydrate` suffix added automatically) | + +--- + +## Examples + +```bash +php artisan modularity:make:input:hydrate ColorPicker +# → src/Hydrates/Inputs/ColorPickerHydrate.php +``` + +```bash +php artisan modularity:make:input:hydrate rich-text +# → src/Hydrates/Inputs/RichTextHydrate.php +``` + +--- + +## Output + +`src/Hydrates/Inputs/{Name}Hydrate.php` + +**Stub**: `input-hydrate.stub` + +--- + +## Notes + +- This command writes to the Modularous **vendor** path. +- Pair with [`make:vue:input`](./vue-input) to create both the Vue component and its PHP hydrate class. +- Use [`make:feature`](./feature) to create both in one interactive wizard. + +--- + +## See also + +- [make:vue:input](./vue-input) — create the Vue input component +- [make:feature](./feature) — wizard that can create both together +- [System Reference](/system-reference/backend/console/make#makeinputhydratecommand) diff --git a/docs/src/pages/guide/console/make/laravel-test.md b/docs/src/pages/guide/console/make/laravel-test.md new file mode 100644 index 000000000..49e8d6413 --- /dev/null +++ b/docs/src/pages/guide/console/make/laravel-test.md @@ -0,0 +1,70 @@ +--- +sidebarPos: 25 +sidebarTitle: make:laravel:test +--- + +# make:laravel:test + +> Scaffold a PHPUnit Feature or Unit test file for a module + +**Signature**: `modularity:make:laravel:test` + +**Alias**: `modularity:create:laravel:test` + +**Category**: Make + +--- + +## Description + +Creates a PHPUnit test file using `LaravelTestGenerator`. Accepts a module and test name. Pass `--unit` to generate a Unit test; the default is a Feature test. + +--- + +## Usage + +``` +modularity:make:laravel:test [options] <module> <test> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | Target module | +| `test` | yes | Test class name | + +### Options + +| Option | Description | +|--------|-------------| +| `--unit` | Generate as a PHPUnit Unit test instead of Feature test | + +--- + +## Examples + +### Feature test + +```bash +php artisan modularity:make:laravel:test Blog PostControllerTest +``` + +### Unit test + +```bash +php artisan modularity:make:laravel:test Blog PostRepositoryTest --unit +``` + +--- + +## Notes + +- This command uses `LaravelTestGenerator` — refer to the generator documentation for output path and stub details. + +--- + +## See also + +- [make:vue:test](./vue-test) — create a Vitest test for Vue +- [System Reference](/system-reference/backend/console/make#makelaraveltestcommand) diff --git a/docs/src/pages/guide/console/make/listener.md b/docs/src/pages/guide/console/make/listener.md new file mode 100644 index 000000000..6a52100ba --- /dev/null +++ b/docs/src/pages/guide/console/make/listener.md @@ -0,0 +1,92 @@ +--- +sidebarPos: 12 +sidebarTitle: make:listener +--- + +# make:listener + +> Create a Laravel Listener class + +**Signature**: `modularity:make:listener` + +**Category**: Make + +--- + +## Description + +Interactive wizard that generates a Laravel Listener class. Scans all concrete event classes from `app/Events`, the vendor path, and every loaded module, then prompts you to bind the listener to one. Supports queued listeners with configurable connection, queue name, delay, tries count, and a `shouldQueue()` method. + +--- + +## Usage + +``` +modularity:make:listener [options] <name> [<module>] +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Listener class name | +| `module` | no | Target module; omit to create in `app/Listeners/` | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--self` | | Write to Modularous vendor path (`src/Listeners/`) | +| `--force` | `-f` | Overwrite existing file | +| `--should-queue` | | Implement `ShouldQueue`; prompts for connection, queue, delay, tries | +| `--should-handle-events-after-commit` | | Implement `ShouldHandleEventsAfterCommit` | + +--- + +## Examples + +### Basic listener for a module + +```bash +php artisan modularity:make:listener SendPostPublishedNotification Blog +# Prompts: select the event to listen to +``` + +### Queued listener + +```bash +php artisan modularity:make:listener SendWelcomeEmail --should-queue +# Prompts: event, queue connection, queue name, delay (seconds), tries count +``` + +### App-level listener + +```bash +php artisan modularity:make:listener LogOrderShipped +``` + +--- + +## Interactive prompts + +1. Select the event class to bind to (or "No" to skip) +2. If `--should-queue`: queue connection, queue name, delay, tries + +--- + +## Output + +| Condition | Path | +|-----------|------| +| Module provided | `{Module}/Listeners/{Name}.php` | +| No module | `app/Listeners/{Name}.php` | +| `--self` | `src/Listeners/{Name}.php` (vendor) | + +**Stub**: `listener.stub` + +--- + +## See also + +- [make:event](./event) — create the matching event +- [System Reference](/system-reference/backend/console/make#makelistenercommand) diff --git a/docs/src/pages/guide/console/make/migration.md b/docs/src/pages/guide/console/make/migration.md new file mode 100644 index 000000000..f25172718 --- /dev/null +++ b/docs/src/pages/guide/console/make/migration.md @@ -0,0 +1,121 @@ +--- +sidebarPos: 5 +sidebarTitle: make:migration +--- + +# make:migration + +> Create a database migration file + +**Signature**: `modularity:make:migration` + +**Alias**: `mod:m:migration` + +**Category**: Make + +--- + +## Description + +Generates a timestamped migration file for a module or the host app. Supports all standard Laravel migration patterns (`create`, `add`, `delete`, `drop`, `plain`) plus two Modularous-specific pivot patterns. When `--addTranslation` or `--addSlug` traits are active, companion translation/slug migration blocks are appended automatically. + +--- + +## Usage + +``` +modularity:make:migration [options] <name> [<module>] +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Migration name (e.g. `create_posts_table`, `add_status_to_posts_table`) | +| `module` | no | Target module; omit for `database/migrations/` | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--fields=` | | Schema field string (`title:string,body:text`) | +| `--route=` | | Route name used for pivot table naming | +| `--relational=` | | Pivot type: `BelongsToMany` or `MorphedByMany` | +| `--table-name=` | | Override auto-derived table name | +| `--plain` | | Create an empty migration body | +| `--self` | | Write to Modularous vendor migrations | +| `--force` | `-f` | Overwrite existing files | +| `--no-defaults` | | Skip Modularous default fields | +| `--test` | | Dry-run / preview mode | + +#### Trait flags (affect extra schema blocks) + +`-T` / `--addTranslation`, `-S` / `--addSlug` + +--- + +## Examples + +### Create table migration + +```bash +php artisan modularity:make:migration create_posts_table Blog \ + --fields="title:string,body:text,published_at:timestamp:nullable" +``` + +### Add column to existing table + +```bash +php artisan modularity:make:migration add_status_to_posts_table Blog \ + --fields="status:string" +``` + +### Pivot table (BelongsToMany) + +```bash +php artisan modularity:make:migration create_blog_post_tag_table Blog \ + --relational=BelongsToMany \ + --route=post \ + --fields="tag_id:unsignedBigInteger" +``` + +### Morph pivot table + +```bash +php artisan modularity:make:migration create_taggables_table Blog \ + --relational=MorphedByMany +``` + +### Migration with translation table + +```bash +php artisan modularity:make:migration create_posts_table Blog \ + --fields="title:string,body:text" \ + --addTranslation +``` + +### Plain migration (empty up/down) + +```bash +php artisan modularity:make:migration add_indexes_to_posts_table Blog --plain +``` + +--- + +## Migration naming conventions + +| Name pattern | Migration type | +|-------------|---------------| +| `create_*_table` | `Schema::create()` | +| `add_*_to_*_table` | `$table->addColumn()` | +| `delete_*_from_*_table` | `$table->dropColumn()` | +| `drop_*_table` | `Schema::drop()` | +| anything else | plain | + +--- + +## See also + +- [make:model](./model) — generate the matching model +- [make:route](./route) — generates model + migration together +- [System Reference](/system-reference/backend/console/make#makemigrationcommand) diff --git a/docs/src/pages/guide/console/make/model-trait.md b/docs/src/pages/guide/console/make/model-trait.md new file mode 100644 index 000000000..335102977 --- /dev/null +++ b/docs/src/pages/guide/console/make/model-trait.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 16 +sidebarTitle: make:model:trait +--- + +# make:model:trait + +> Create a reusable entity trait + +**Signature**: `modularity:make:model:trait` + +**Aliases**: `modularity:create:model:trait`, `mod:c:model:trait` + +**Category**: Make + +--- + +## Description + +Creates a skeleton PHP trait in the Modularous vendor `src/Entities/Traits/` directory. The file is automatically named `Has{Name}.php`, following the Modularous entity trait naming convention. + +--- + +## Usage + +``` +modularity:make:model:trait <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Trait name (the `Has` prefix is added automatically) | + +--- + +## Examples + +```bash +php artisan modularity:make:model:trait Taggable +# → src/Entities/Traits/HasTaggable.php +``` + +```bash +php artisan modularity:make:model:trait PriceRange +# → src/Entities/Traits/HasPriceRange.php +``` + +--- + +## Output + +`src/Entities/Traits/Has{Name}.php` + +**Stub**: `classes/model-trait.stub` + +--- + +## Notes + +- This command writes to the Modularous **vendor** path, not a module. Use it when creating reusable traits that span multiple modules. +- The trait is not registered anywhere automatically — add it to `config/modularity.php` traits list to make it available in `make:model`. + +--- + +## See also + +- [make:repository:trait](./repository-trait) — create a repository trait +- [make:feature](./feature) — wizard that can call this command as part of a bundle +- [System Reference](/system-reference/backend/console/make#makemodeltraitcommand) diff --git a/docs/src/pages/guide/console/make/model.md b/docs/src/pages/guide/console/make/model.md new file mode 100644 index 000000000..d16ffc32a --- /dev/null +++ b/docs/src/pages/guide/console/make/model.md @@ -0,0 +1,122 @@ +--- +sidebarPos: 4 +sidebarTitle: make:model +--- + +# make:model + +> Create an Eloquent model for a module + +**Signature**: `modularity:make:model` + +**Alias**: `mod:m:model` + +**Category**: Make + +--- + +## Description + +Generates an Eloquent model class with optional trait composition, relationship methods, and fillable fields. When traits like `addTranslation` or `addSlug` are enabled, companion models (`{Name}Translation`, `{Name}Slug`) are automatically created alongside the main model. When `--relationships` is provided, many-to-many pivot models are also created. + +--- + +## Usage + +``` +modularity:make:model [options] <model> [<module>] +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `model` | yes | Model class name (e.g. `Post`, `ProductVariant`) | +| `module` | no | Target module; omit to create in `app/Models/` | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--fillable=` | | Comma-separated fillable field names | +| `--relationships=` | | Relationship definition string | +| `--override-model=` | | Fully-qualified base class to extend instead of the default | +| `--self` | | Write to the Modularous vendor path | +| `--force` | `-f` | Overwrite existing files | +| `--soft-delete` | `-s` | Add `SoftDeletes` trait | +| `--has-factory` | | Add `HasFactory` trait and `newFactory()` method | +| `--no-defaults` | | Skip Modularous default fillable fields | +| `--notAsk` | | Skip interactive trait questions | +| `--all` | | Accept all trait questions | +| `--test` | | Dry-run / preview mode | + +#### Trait flags + +| Option | Short | Description | +|--------|-------|-------------| +| `--addTranslation` | `-T` | Creates a `{Name}Translation` companion model | +| `--addMedia` | `-M` | Attach `HasMedia` trait | +| `--addFile` | `-F` | Attach `HasFile` trait | +| `--addPosition` | `-P` | Attach `HasPosition` trait | +| `--addSlug` | `-S` | Creates a `{Name}Slug` companion model | +| `--addAuthorized` | `-A` | Attach authorization scope trait | +| `--addFilepond` | `-FP` | Attach FilePond trait | +| `--addUuid` | | Use UUID primary key | +| `--addSnapshot` | `-SS` | Attach snapshot/versioning trait | +| `--addPrice` | | Attach pricing trait | + +--- + +## Examples + +### Basic model for a module + +```bash +php artisan modularity:make:model Post Blog +``` + +### Model with fillable and soft-delete + +```bash +php artisan modularity:make:model Post Blog \ + --fillable="title,body,published_at" \ + --soft-delete +``` + +### Translatable model with media + +```bash +php artisan modularity:make:model Post Blog --addTranslation --addMedia +``` + +### Standalone model in app/Models (no module) + +```bash +php artisan modularity:make:model Article +``` + +### Preview without writing + +```bash +php artisan modularity:make:model Post Blog --test +``` + +--- + +## Output files + +| Condition | File created | +|-----------|--------------| +| Always | `{Module}/Entities/Post.php` | +| `--addTranslation` | `{Module}/Entities/Translations/PostTranslation.php` | +| `--addSlug` | `{Module}/Entities/Slugs/PostSlug.php` | +| `--relationships` (BelongsToMany) | Pivot model(s) in `{Module}/Entities/` | + +--- + +## See also + +- [make:migration](./migration) — create the matching migration +- [make:repository](./repository) — create the matching repository +- [make:model:trait](./model-trait) — create a reusable entity trait +- [System Reference](/system-reference/backend/console/make#makemodelcommand) diff --git a/docs/src/pages/guide/console/make/module.md b/docs/src/pages/guide/console/make/module.md new file mode 100644 index 000000000..74e51329b --- /dev/null +++ b/docs/src/pages/guide/console/make/module.md @@ -0,0 +1,124 @@ +--- +sidebarPos: 2 +sidebarTitle: make:module +--- + +# make:module + +> Bootstrap a complete module + +**Signature**: `modularity:make:module` + +**Aliases**: `m:m:m`, `unusual:make:module` + +**Category**: Make + +--- + +## Description + +`make:module` is the primary entry point for creating a new Modularous module. It calls nWidart's `module:make` to create the folder skeleton, then immediately calls [`make:route`](./route) with the same module name to generate the full first-route file set (model, controller, repository, migration, form request, and Vue stubs). + +--- + +## Usage + +``` +modularity:make:module [options] <module> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | PascalCase module name (e.g. `Blog`, `UserProfile`) | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--schema=` | | Schema fields for the initial migration (`title:string,body:text`) | +| `--rules=` | | Validation rules for the Form Request | +| `--relationships=` | | Relationship definitions forwarded to the model | +| `--custom-model=` | | Reuse an existing model class instead of generating a new one | +| `--table-name=` | | Override the auto-derived table name | +| `--force` | `-f` | Overwrite existing files | +| `--system` | | Create inside the Modularous system modules path | +| `--no-migrate` | | Skip running migrations after generation | +| `--no-migration` | | Skip creating the migration file entirely | +| `--no-defaults` | | Skip Modularous default fields (e.g. `is_published`) | +| `--notAsk` | | Skip all interactive trait questions | +| `--all` | | Accept all trait questions with `yes` | +| `--just-stubs` | | Only regenerate stubs for existing module routes | +| `--stubs-only=` | | Comma-separated list of stub types to include (used with `--just-stubs`) | +| `--stubs-except=` | | Comma-separated list of stub types to exclude (used with `--just-stubs`) | +| `--test` | | Dry-run / preview mode | + +#### Trait flags + +| Option | Short | Description | +|--------|-------|-------------| +| `--addTranslation` | `-T` | Add translatable content support | +| `--addMedia` | `-M` | Add media/image attachment support | +| `--addFile` | `-F` | Add file attachment support | +| `--addPosition` | `-P` | Add sortable position support | +| `--addSlug` | `-S` | Add slug generation support | +| `--addAuthorized` | `-A` | Add scoped authorization | +| `--addFilepond` | `-FP` | Add FilePond file upload support | +| `--addUuid` | | Add UUID primary key support | +| `--addSnapshot` | `-SS` | Add snapshot/versioning support | +| `--addPrice` | | Add pricing trait | + +--- + +## Examples + +### Minimal module + +```bash +php artisan modularity:make:module Blog +``` + +### Module with schema and translation + +```bash +php artisan modularity:make:module Blog --schema="title:string,body:text" --addTranslation +``` + +### Module with all options set non-interactively + +```bash +php artisan modularity:make:module Shop \ + --schema="name:string,price:decimal:8,2" \ + --rules="name:required|string,price:required|numeric" \ + --addTranslation \ + --addMedia \ + --addPrice \ + --notAsk +``` + +### Re-generate stubs only for an existing module + +```bash +php artisan modularity:make:module Blog --just-stubs --stubs-except=migration +``` + +### Preview without writing files + +```bash +php artisan modularity:make:module Blog --test +``` + +--- + +## Notes + +- `make:module` wraps `module:make` (nWidart) + `make:route` — it does not generate anything itself. +- Pass `--system` only when adding to the Modularous core; requires a non-production environment. +- Use `--just-stubs` to fix or refresh stub files after changing the config without re-running migrations. + +## See also + +- [make:route](./route) — add a subsequent route to the same module +- [make:stubs](./stubs) — regenerate specific stub files +- [System Reference](/system-reference/backend/console/make#makemodulecommand) — class internals diff --git a/docs/src/pages/guide/console/make/operation.md b/docs/src/pages/guide/console/make/operation.md new file mode 100644 index 000000000..dc8e4d50d --- /dev/null +++ b/docs/src/pages/guide/console/make/operation.md @@ -0,0 +1,86 @@ +--- +sidebarPos: 13 +sidebarTitle: make:operation +--- + +# make:operation + +> Create a one-time operation file + +**Signature**: `modularity:make:operation` + +**Aliases**: `modularity:create:operation`, `mod:c:operation`, `modularity:operations:make` + +**Category**: Make + +--- + +## Description + +Generates a timestamped one-time operation file for [timokoerber/laravel-one-time-operations](https://github.com/timokoerber/laravel-one-time-operations). Operation files are run exactly once via the operations pipeline. Use `--self` to create a Modularous-internal operation (tagged `modularity`) in the vendor `operations/` folder. + +--- + +## Usage + +``` +modularity:make:operation [options] <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Operation name (snake-cased in filename) | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--self` | | Write to Modularous vendor `operations/` and tag as `modularity` | +| `--path=` | | Custom output directory (default: `config('one-time-operations.directory')`) | +| `--tag=` | `-t` | Tag string for the operation | +| `--async` | | Mark the operation as asynchronous | +| `--queue=` | | Queue name (default: `default`) | + +--- + +## Examples + +### Standard one-time operation + +```bash +php artisan modularity:make:operation SeedNewPermissions +# → operations/2026_04_28_120000_seed_new_permissions_operation.php +``` + +### Tagged async operation on a custom queue + +```bash +php artisan modularity:make:operation BackfillPostSlugs \ + --tag=backfill \ + --async \ + --queue=high-priority +``` + +### Internal Modularous operation + +```bash +php artisan modularity:make:operation AddDefaultSettings --self +# → src/operations/2026_04_28_120000_modularity_add_default_settings_operation.php +``` + +--- + +## Output + +`{path}/{timestamp}_{name}_operation.php` + +**Stub**: `operation.stub` + +--- + +## See also + +- [Operations: process-operations](/guide/console/operations/process-operations) — run all pending operations +- [System Reference](/system-reference/backend/console/make#makeoperationcommand) diff --git a/docs/src/pages/guide/console/make/overview.md b/docs/src/pages/guide/console/make/overview.md new file mode 100644 index 000000000..8eff59e10 --- /dev/null +++ b/docs/src/pages/guide/console/make/overview.md @@ -0,0 +1,106 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Make +--- + +# Make Commands + +The `make:*` group scaffolds every layer of a Modularous application — from a full module skeleton to individual traits, Vue components, and test files. Every command lives under `src/Console/Make/` and extends `BaseCommand`. + +## Module & Route Scaffold + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:module](./module) | `modularity:make:module` | Bootstrap a complete module (nWidart skeleton + first route) | +| [make:route](./route) | `modularity:make:route` | Add a new route to an existing module | +| [make:stubs](./stubs) | `modularity:make:stubs` | Selectively regenerate stub files for an existing route | + +## Models & Data + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:model](./model) | `modularity:make:model` | Eloquent model with optional traits, relations, and companion models | +| [make:migration](./migration) | `modularity:make:migration` | Database migration (create, pivot, morph-pivot, add, drop) | +| [make:repository](./repository) | `modularity:make:repository` | Repository class bound to a module model | +| [make:model:trait](./model-trait) | `modularity:make:model:trait` | Reusable entity trait (`Has{Name}.php`) | +| [make:repository:trait](./repository-trait) | `modularity:make:repository:trait` | Reusable repository trait (`{Name}Trait.php`) | + +## Controllers & Requests + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:controller](./controller) | `modularity:make:controller` | Admin-panel CRUD controller | +| [make:controller:api](./controller-api) | `modularity:make:controller:api` | REST API controller | +| [make:controller:front](./controller-front) | `modularity:make:controller:front` | Front-end (public-facing) controller | +| [make:request](./request) | `modularity:make:request` | Form Request with inline validation rules | +| [make:route:permissions](./route-permissions) | `modularity:make:route:permissions` | Generate Spatie permission records for a route | + +## Events & Async + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:event](./event) | `modularity:make:event` | Laravel Event class (broadcasting, deferred dispatch) | +| [make:listener](./listener) | `modularity:make:listener` | Laravel Listener class (queued, after-commit) | +| [make:operation](./operation) | `modularity:make:operation` | One-time operation file for the operations pipeline | +| [make:horizon:supervisor](./horizon-supervisor) | `modularity:make:horizon:supervisor` | Supervisor `.conf` for Laravel Horizon | + +## Console + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:command](./command) | `modularity:make:command` | New Artisan command class inside the Modularous vendor path | + +## Themes & Frontend + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:theme:folder](./theme-folder) | `modularity:make:theme:folder` | Scaffold a custom theme working folder | +| [make:theme](./theme) | `modularity:make:theme` | Promote a custom theme into the built-in theme set | +| [make:input:hydrate](./input-hydrate) | `modularity:make:input:hydrate` | PHP Hydrate class for a Vue input component | +| [make:vue:input](./vue-input) | `modularity:make:vue:input` | Vue single-file input component | + +## Tests + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:laravel:test](./laravel-test) | `modularity:make:laravel:test` | PHPUnit Feature or Unit test file | +| [make:vue:test](./vue-test) | `modularity:make:vue:test` | Vitest/Jest test file for a Vue component or composable | + +## Composite Wizard + +| Command | Signature | Description | +|---------|-----------|-------------| +| [make:feature](./feature) | `modularity:make:feature` | Interactive wizard that orchestrates multiple make commands | + +--- + +## Common Workflows + +### Scaffold a brand-new module + +```bash +php artisan modularity:make:module Blog --schema="title:string,body:text" --addTranslation +``` + +### Add a second route to an existing module + +```bash +php artisan modularity:make:route Blog Post --schema="title:string,published_at:timestamp:nullable" +``` + +### Create a standalone model + migration + +```bash +php artisan modularity:make:model Tag Blog --soft-delete +php artisan modularity:make:migration create_blog_tags_table Blog --fields="tag_id:unsignedBigInteger" +``` + +### Scaffold a Vue input feature end-to-end + +```bash +php artisan modularity:make:feature +# Responds to all prompts interactively +``` + +> For the class internals of these commands see [System Reference → Console → Make](/system-reference/backend/console/make). diff --git a/docs/src/pages/guide/console/make/repository-trait.md b/docs/src/pages/guide/console/make/repository-trait.md new file mode 100644 index 000000000..ecee86d69 --- /dev/null +++ b/docs/src/pages/guide/console/make/repository-trait.md @@ -0,0 +1,70 @@ +--- +sidebarPos: 17 +sidebarTitle: make:repository:trait +--- + +# make:repository:trait + +> Create a reusable repository trait + +**Signature**: `modularity:make:repository:trait` + +**Aliases**: `modularity:create:repository:trait`, `mod:c:repo:trait` + +**Category**: Make + +--- + +## Description + +Creates a skeleton PHP trait in the Modularous vendor `src/Repositories/Traits/` directory. The file is named `{Name}Trait.php`. + +--- + +## Usage + +``` +modularity:make:repository:trait <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Trait name (studly-cased; `Trait` suffix added automatically in filename) | + +--- + +## Examples + +```bash +php artisan modularity:make:repository:trait HasTagging +# → src/Repositories/Traits/HasTaggingTrait.php +``` + +```bash +php artisan modularity:make:repository:trait Filterable +# → src/Repositories/Traits/FilterableTrait.php +``` + +--- + +## Output + +`src/Repositories/Traits/{Name}Trait.php` + +**Stub**: `classes/repository-trait.stub` + +--- + +## Notes + +- Writes to the Modularous **vendor** path. Register the trait in `config/modularity.php` to make it available in `make:repository`. + +--- + +## See also + +- [make:model:trait](./model-trait) — create an entity trait +- [make:feature](./feature) — wizard that can call this command +- [System Reference](/system-reference/backend/console/make#makerepositoryrtraitcommand) diff --git a/docs/src/pages/guide/console/make/repository.md b/docs/src/pages/guide/console/make/repository.md new file mode 100644 index 000000000..be701c239 --- /dev/null +++ b/docs/src/pages/guide/console/make/repository.md @@ -0,0 +1,79 @@ +--- +sidebarPos: 6 +sidebarTitle: make:repository +--- + +# make:repository + +> Create a repository class for a module + +**Signature**: `modularity:make:repository` + +**Category**: Make + +--- + +## Description + +Generates a repository class for a module, pre-wired to the matching Eloquent model. Interactively asks which Modularous repository traits to compose (e.g. `HasTranslation`, `HasMedia`) unless `--notAsk` or `--all` is passed. Use `--custom-model` to bind the repository to any existing model class. + +--- + +## Usage + +``` +modularity:make:repository [options] <repository> <module> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `repository` | yes | Repository class name (e.g. `Post`, `ProductVariant`) | +| `module` | yes | Target module | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--custom-model=` | | Fully-qualified model class to bind instead of auto-resolved | +| `--force` | `-f` | Overwrite existing files | +| `--notAsk` | | Skip interactive trait questions | +| `--all` | | Accept all trait questions | + +--- + +## Examples + +### Basic repository + +```bash +php artisan modularity:make:repository Post Blog +``` + +### Repository bound to a custom model + +```bash +php artisan modularity:make:repository Order Shop \ + --custom-model="App\Models\Order" +``` + +### Non-interactive with all traits + +```bash +php artisan modularity:make:repository Post Blog --all --notAsk +``` + +--- + +## Output + +`{Module}/Repositories/PostRepository.php` + +--- + +## See also + +- [make:model](./model) — create the matching model +- [make:repository:trait](./repository-trait) — create a standalone repository trait +- [System Reference](/system-reference/backend/console/make#makerepositoryrcommand) diff --git a/docs/src/pages/guide/console/make/request.md b/docs/src/pages/guide/console/make/request.md new file mode 100644 index 000000000..ec941030c --- /dev/null +++ b/docs/src/pages/guide/console/make/request.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 10 +sidebarTitle: make:request +--- + +# make:request + +> Create a Form Request class for a module + +**Signature**: `modularity:make:request` + +**Category**: Make + +--- + +## Description + +Generates a Form Request extending the configured `base_request`. The `--rules` string is parsed by `ValidatorParser` and inlined as a PHP array into the generated class. + +--- + +## Usage + +``` +modularity:make:request [options] <module> <request> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | Target module | +| `request` | yes | Request class name (suffix `Request` added automatically) | + +### Options + +| Option | Description | +|--------|-------------| +| `--rules=` | Validation rules string (e.g. `name:required\|string,email:required\|email`) | + +--- + +## Examples + +### Basic request + +```bash +php artisan modularity:make:request Blog Post +# → Blog/Http/Requests/PostRequest.php +``` + +### Request with inline rules + +```bash +php artisan modularity:make:request Blog Post \ + --rules="title:required|string|max:255,body:required|string,published_at:nullable|date" +``` + +--- + +## Output + +`{Module}/Http/Requests/{Request}Request.php` + +**Stub**: `route-request.stub` + +--- + +## See also + +- [make:route](./route) — generates the request as part of a full scaffold +- [System Reference](/system-reference/backend/console/make#makerequestcommand) diff --git a/docs/src/pages/guide/console/make/route-permissions.md b/docs/src/pages/guide/console/make/route-permissions.md new file mode 100644 index 000000000..a1e6b1082 --- /dev/null +++ b/docs/src/pages/guide/console/make/route-permissions.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 18 +sidebarTitle: make:route:permissions +--- + +# make:route:permissions + +> Generate Spatie permission records for a route + +**Signature**: `modularity:make:route:permissions` + +**Alias**: `modularity:create:route:permissions` + +**Category**: Make + +--- + +## Description + +Creates the Spatie permission database records for an existing route by calling `RouteGenerator::createRoutePermissions()`. Useful when a route was added manually or renamed without re-running `make:route`. + +--- + +## Usage + +``` +modularity:make:route:permissions <route> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `route` | yes | Route name (must already exist in the config) | + +--- + +## Examples + +```bash +php artisan modularity:make:route:permissions Post +``` + +```bash +php artisan modularity:make:route:permissions ProductCategory +``` + +--- + +## Notes + +- The route must be registered in Modularous configuration before running this command. +- Permissions follow the Modularity naming convention: `{route}.index`, `{route}.create`, `{route}.edit`, `{route}.destroy`. + +--- + +## See also + +- [make:route](./route) — automatically runs permission creation during scaffold +- [System Reference](/system-reference/backend/console/make#makeroutepermissionscommand) diff --git a/docs/src/pages/guide/console/make/route.md b/docs/src/pages/guide/console/make/route.md new file mode 100644 index 000000000..dd044d79d --- /dev/null +++ b/docs/src/pages/guide/console/make/route.md @@ -0,0 +1,106 @@ +--- +sidebarPos: 3 +sidebarTitle: make:route +--- + +# make:route + +> Add a new route to an existing module + +**Signature**: `modularity:make:route` + +**Aliases**: `m:m:r`, `u:m:r`, `unusual:make:route` + +**Category**: Make + +--- + +## Description + +`make:route` scaffolds the complete file set needed for one CRUD route inside an existing module: model, migration, controller(s), repository, form request, Vue stubs, and permission seeds. It delegates to [`RouteGenerator`](/system-reference/backend/generators/route-generator) which orchestrates all file creation in the correct order. + +--- + +## Usage + +``` +modularity:make:route [options] <module> <route> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | The module that owns this route | +| `route` | yes | Route name (e.g. `Post`, `ProductCategory`) | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--schema=` | | Schema fields for the migration (`title:string,body:text`) | +| `--rules=` | | Validation rules for the Form Request | +| `--relationships=` | | Relationship definitions (forwarded to model + migration) | +| `--custom-model=` | | Reuse an existing model class | +| `--table-name=` | | Override the auto-derived table name | +| `--force` | `-f` | Overwrite existing files | +| `--plain` | `-p` | Skip route file creation (model + migration only) | +| `--no-migrate` | | Skip running migrations after generation | +| `--no-migration` | | Skip creating the migration file | +| `--no-defaults` | | Skip Modularous default fields | +| `--notAsk` | | Skip all interactive trait questions | +| `--all` | | Accept all trait questions with `yes` | +| `--fix` | | Fix model config errors on an existing route | +| `--test` | | Dry-run / preview mode | + +#### Trait flags (same as make:module) + +`-T` / `--addTranslation`, `-M` / `--addMedia`, `-F` / `--addFile`, `-P` / `--addPosition`, `-S` / `--addSlug`, `-A` / `--addAuthorized`, `-FP` / `--addFilepond`, `--addUuid`, `-SS` / `--addSnapshot`, `--addPrice` + +--- + +## Examples + +### Minimal route + +```bash +php artisan modularity:make:route Blog Post +``` + +### Route with schema and rules + +```bash +php artisan modularity:make:route Blog Post \ + --schema="title:string,body:text,published_at:timestamp:nullable" \ + --rules="title:required|string|max:255,body:required|string" +``` + +### Route with translation and media + +```bash +php artisan modularity:make:route Blog Post --addTranslation --addMedia +``` + +### Route using an existing model (no new model/migration) + +```bash +php artisan modularity:make:route Shop Order \ + --custom-model="App\Models\Order" \ + --no-migration \ + --no-defaults +``` + +### Preview output without writing + +```bash +php artisan modularity:make:route Blog Post --test +``` + +--- + +## See also + +- [make:module](./module) — create the parent module first +- [make:model](./model) — generate only the model +- [make:migration](./migration) — generate only the migration +- [System Reference](/system-reference/backend/console/make#makeroutecommand) — class internals diff --git a/docs/src/pages/guide/console/make/stubs.md b/docs/src/pages/guide/console/make/stubs.md new file mode 100644 index 000000000..0e969143a --- /dev/null +++ b/docs/src/pages/guide/console/make/stubs.md @@ -0,0 +1,79 @@ +--- +sidebarPos: 19 +sidebarTitle: make:stubs +--- + +# make:stubs + +> Selectively regenerate stub files for an existing route + +**Signature**: `modularity:make:stubs` + +**Category**: Make + +--- + +## Description + +Re-runs stub generation for an existing module route without creating migrations or running them. Delegates to `StubsGenerator` which writes only the PHP class files whose stub types match the `--only` / `--except` filters. Use this to fix outdated controller or repository files after a config change. + +--- + +## Usage + +``` +modularity:make:stubs [options] <module> <route> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | yes | Target module | +| `route` | yes | Route name | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--only=` | | Comma-separated list of stub types to include | +| `--except=` | | Comma-separated list of stub types to exclude | +| `--force` | `-f` | Overwrite existing files | +| `--fix` | | Fix model config errors | + +--- + +## Examples + +### Regenerate all stubs for a route + +```bash +php artisan modularity:make:stubs Blog Post --force +``` + +### Regenerate only the controller stubs + +```bash +php artisan modularity:make:stubs Blog Post --only=controller,controller-api +``` + +### Regenerate all except migration + +```bash +php artisan modularity:make:stubs Blog Post --except=migration +``` + +--- + +## Notes + +- Stub type names match the generator config keys (e.g. `model`, `controller`, `repository`, `request`, `migration`). +- Use `make:module --just-stubs` to regenerate stubs across all routes of a module at once. + +--- + +## See also + +- [make:route](./route) — full route scaffold including migrations +- [make:module](./module) — `--just-stubs` flag for bulk stub refresh +- [System Reference](/system-reference/backend/console/make#makestubscommand) diff --git a/docs/src/pages/guide/console/make/theme-folder.md b/docs/src/pages/guide/console/make/theme-folder.md new file mode 100644 index 000000000..8f330a062 --- /dev/null +++ b/docs/src/pages/guide/console/make/theme-folder.md @@ -0,0 +1,82 @@ +--- +sidebarPos: 20 +sidebarTitle: make:theme:folder +--- + +# make:theme:folder + +> Scaffold a custom theme working folder + +**Signature**: `modularity:make:theme:folder` + +**Alias**: `modularity:create:theme` + +**Category**: Make + +--- + +## Description + +Creates a new theme working directory at `resources/vendor/modularity/themes/{name}/` by copying the Sass and JS files from an existing built-in theme. The copied files serve as a starting point; edit them freely before promoting the theme with [`make:theme`](./theme). + +--- + +## Usage + +``` +modularity:make:theme:folder [options] <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | New theme name (used as folder name) | + +### Options + +| Option | Description | +|--------|-------------| +| `--extend=` | Built-in theme to copy as base. Prompts with a select list if omitted or invalid. | +| `--force` | Overwrite existing files | + +--- + +## Examples + +### Interactive (prompts for base theme) + +```bash +php artisan modularity:make:theme:folder mytheme +``` + +### With explicit base theme + +```bash +php artisan modularity:make:theme:folder mytheme --extend=unusualify +``` + +--- + +## Output + +``` +resources/vendor/modularity/themes/mytheme/ +├── sass/ (copied from built-in theme) +└── mytheme.js (copied from built-in theme) +``` + +--- + +## Workflow + +1. Run `make:theme:folder` to scaffold the working directory +2. Edit `resources/vendor/modularity/themes/{name}/` to customize +3. Run [`make:theme`](./theme) to promote the custom theme to the built-in set + +--- + +## See also + +- [make:theme](./theme) — promote a custom theme to built-in +- [System Reference](/system-reference/backend/console/make#makethemefoldercommand) diff --git a/docs/src/pages/guide/console/make/theme.md b/docs/src/pages/guide/console/make/theme.md new file mode 100644 index 000000000..f2f8d9923 --- /dev/null +++ b/docs/src/pages/guide/console/make/theme.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 21 +sidebarTitle: make:theme +--- + +# make:theme + +> Promote a custom theme into the built-in theme set + +**Signature**: `modularity:make:theme` + +**Category**: Make + +--- + +## Description + +Moves a completed custom theme from `resources/vendor/modularity/themes/{name}/` into the Modularous vendor asset paths. It copies JS and Sass files to `vue/src/js/config/themes/` and `vue/src/sass/themes/`, removes the `customs/` variants, deletes the source from `resources/`, and appends an export line to the themes `index.js` so the theme is available in the Vue build. + +--- + +## Usage + +``` +modularity:make:theme [options] <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Theme name (must match the folder under `resources/vendor/modularity/themes/`) | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--force` | `-f` | Overwrite existing files | + +--- + +## Examples + +```bash +php artisan modularity:make:theme mytheme +``` + +--- + +## What this command does + +1. Copies `resources/vendor/modularity/themes/mytheme/mytheme.js` → `vue/src/js/config/themes/mytheme.js` +2. Copies `resources/vendor/modularity/themes/mytheme/sass/` → `vue/src/sass/themes/mytheme/` +3. Deletes `vue/src/js/config/themes/customs/mytheme.js` and `vue/src/sass/themes/customs/mytheme/` +4. Deletes `resources/vendor/modularity/themes/mytheme/` +5. Appends to `vue/src/js/config/themes/index.js`: + ```js + export { default as mytheme } from './mytheme' + ``` + +--- + +## Prerequisites + +Run [`make:theme:folder`](./theme-folder) first to create and customize the theme. + +--- + +## See also + +- [make:theme:folder](./theme-folder) — create the theme working folder +- [System Reference](/system-reference/backend/console/make#makethemecommand) diff --git a/docs/src/pages/guide/console/make/vue-input.md b/docs/src/pages/guide/console/make/vue-input.md new file mode 100644 index 000000000..3a0aab6fe --- /dev/null +++ b/docs/src/pages/guide/console/make/vue-input.md @@ -0,0 +1,75 @@ +--- +sidebarPos: 23 +sidebarTitle: make:vue:input +--- + +# make:vue:input + +> Create a Vue single-file input component + +**Signature**: `modularity:make:vue:input` + +**Aliases**: `modularity:create:vue:input`, `mod:c:vue:input` + +**Category**: Make + +--- + +## Description + +Scaffolds a Vue `.vue` file for a custom form input type in the Modularous vendor `vue/src/js/components/inputs/` directory. The file name is the studly-cased component name; the component's `name` attribute uses a `v-input-` kebab-case prefix. The command is a no-op if the file already exists. + +--- + +## Usage + +``` +modularity:make:vue:input <name> +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | yes | Component name (e.g. `ColorPicker`, `RichText`) | + +--- + +## Examples + +```bash +php artisan modularity:make:vue:input ColorPicker +# → vue/src/js/components/inputs/ColorPicker.vue +# Component name attribute: v-input-color-picker +``` + +```bash +php artisan modularity:make:vue:input RichText +# → vue/src/js/components/inputs/RichText.vue +# Component name attribute: v-input-rich-text +``` + +--- + +## Output + +`vue/src/js/components/inputs/{Name}.vue` + +**Stub**: `input-component.vue` + +--- + +## Notes + +- This command writes to the Modularous **vendor** path. +- After creating the component, create the matching PHP Hydrate class with [`make:input:hydrate`](./input-hydrate). +- Use [`make:feature`](./feature) to run both commands as part of an interactive wizard. + +--- + +## See also + +- [make:input:hydrate](./input-hydrate) — create the matching PHP hydrate class +- [make:vue:test](./vue-test) — create a test for this component +- [make:feature](./feature) — end-to-end wizard +- [System Reference](/system-reference/backend/console/make#makevueinputcommand) diff --git a/docs/src/pages/guide/console/make/vue-test.md b/docs/src/pages/guide/console/make/vue-test.md new file mode 100644 index 000000000..64c06ba9b --- /dev/null +++ b/docs/src/pages/guide/console/make/vue-test.md @@ -0,0 +1,88 @@ +--- +sidebarPos: 24 +sidebarTitle: make:vue:test +--- + +# make:vue:test + +> Create a Vitest/Jest test file for a Vue feature or component + +**Signature**: `modularity:make:vue:test` + +**Aliases**: `modularity:create:vue:test`, `mod:c:vue:test` + +**Category**: Make + +--- + +## Description + +Scaffolds a Vitest or Jest test file via `VueTestGenerator`. Both the test name and type are prompted interactively if omitted. Multiple test types are supported (component, composable, utility, store). Use `--importDir` to target a subdirectory when the import path differs from the default. + +--- + +## Usage + +``` +modularity:make:vue:test [options] [<name>] [<type>] +``` + +### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `name` | no | Test name (prompted if omitted) | +| `type` | no | Test type: `component`, `composable`, `utility`, `store`, etc. (prompted if omitted) | + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--importDir` | | Subdirectory path used in the import statement | +| `--force` | `-F` | Overwrite existing test file | + +--- + +## Examples + +### Fully interactive + +```bash +php artisan modularity:make:vue:test +# Prompts: test name, test type +``` + +### Component test with explicit name + +```bash +php artisan modularity:make:vue:test VInputColorPicker component +``` + +### Composable test + +```bash +php artisan modularity:make:vue:test UseTagSelector composable +``` + +### Component from a subdirectory + +```bash +php artisan modularity:make:vue:test VInputColorPicker component --importDir=inputs +``` + +--- + +## Notes + +- The generated file path and import statement depend on the test `type` and the optional `--importDir`. +- Run this after [`make:vue:input`](./vue-input) to immediately scaffold the corresponding test file. +- [`make:feature`](./feature) can trigger this automatically during the feature wizard. + +--- + +## See also + +- [make:vue:input](./vue-input) — create the Vue component being tested +- [make:laravel:test](./laravel-test) — create a PHPUnit test instead +- [make:feature](./feature) — wizard that optionally creates both +- [System Reference](/system-reference/backend/console/make#makevuetestcommand) diff --git a/docs/src/pages/guide/console/migration/migrate-refresh.md b/docs/src/pages/guide/console/migration/migrate-refresh.md new file mode 100644 index 000000000..fea7b7e01 --- /dev/null +++ b/docs/src/pages/guide/console/migration/migrate-refresh.md @@ -0,0 +1,38 @@ +--- +sidebarPos: 4 +sidebarTitle: Migrate Refresh +--- + +# Migrate Refresh + +> Rollback then re-run all migrations for a specific module. + +## Command Information + +- **Signature:** `modularity:migrate:refresh {module}` +- **Category:** Database + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name to refresh (e.g. `Blog`) | + +## What It Does + +Calls `modularity:migrate:rollback` followed by `modularity:migrate` for the given module. Use during development to reset a module's schema and replay its migrations cleanly. + +::: warning +This drops and recreates the module's tables. Any data in those tables will be lost. +::: + +## Examples + +```bash +php artisan modularity:migrate:refresh Blog +``` + +## Related + +- [migrate](./migrate) — run a module's migrations +- [migrate:rollback](./migrate-rollback) — rollback only diff --git a/docs/src/pages/guide/console/migration/migrate-rollback.md b/docs/src/pages/guide/console/migration/migrate-rollback.md new file mode 100644 index 000000000..3e6944d6e --- /dev/null +++ b/docs/src/pages/guide/console/migration/migrate-rollback.md @@ -0,0 +1,34 @@ +--- +sidebarPos: 5 +sidebarTitle: Migrate Rollback +--- + +# Migrate Rollback + +> Rollback the migrations for a specific module. + +## Command Information + +- **Signature:** `modularity:migrate:rollback {module}` +- **Category:** Database + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name to roll back (e.g. `Blog`) | + +## What It Does + +Finds all migration files under `Modules/{Module}/Database/Migrations/`, looks up their batch numbers in the `migrations` table, and rolls back each batch — in reverse order. Only that module's migrations are affected. + +## Examples + +```bash +php artisan modularity:migrate:rollback Blog +``` + +## Related + +- [migrate](./migrate) — run a module's migrations +- [migrate:refresh](./migrate-refresh) — rollback and re-run diff --git a/docs/src/pages/guide/console/migration/migrate.md b/docs/src/pages/guide/console/migration/migrate.md new file mode 100644 index 000000000..95d9b9f3d --- /dev/null +++ b/docs/src/pages/guide/console/migration/migrate.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 3 +sidebarTitle: Migrate +--- + +# Migrate + +> Run the migrations for a specific module. + +## Command Information + +- **Signature:** `modularity:migrate {module}` +- **Category:** Database + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name whose migrations should run (e.g. `Blog`) | + +## What It Does + +Resolves the module by name and calls Laravel's `migrate` command pointing at `Modules/{Module}/Database/Migrations/`. Only that module's migrations are run — not the entire application. + +## Examples + +```bash +php artisan modularity:migrate Blog +php artisan modularity:migrate Shop +``` + +## Related + +- [migrate:refresh](./migrate-refresh) — rollback then re-run a module's migrations +- [migrate:rollback](./migrate-rollback) — rollback a module's last migration batch diff --git a/docs/src/pages/guide/console/migration/overview.md b/docs/src/pages/guide/console/migration/overview.md new file mode 100644 index 000000000..2870e0a7c --- /dev/null +++ b/docs/src/pages/guide/console/migration/overview.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 7 +sidebarTitle: Overview +sidebarGroupTitle: Migration +--- + +# Migration Commands + +Run Modularous-aware migrations. These wrap Laravel's migration commands with awareness of module directories — they scan `modules/*/Database/Migrations/` in addition to the host app's `database/migrations/`. + +| Command | Signature | Description | +|---------|-----------|-------------| +| [migrate](./migrate) | `modularity:migrate` | Run all module migrations | +| [migrate:refresh](./migrate-refresh) | `modularity:migrate:refresh` | Rollback and re-run all module migrations | +| [migrate:rollback](./migrate-rollback) | `modularity:migrate:rollback` | Rollback the last batch of module migrations | + +## Common Workflows + +### Apply new migrations during development + +```bash +php artisan modularity:migrate +``` + +Runs any unmigrated files across all modules. Safe to re-run; already-applied migrations are skipped. + +### Reset and re-seed a local database + +```bash +php artisan modularity:migrate:refresh +php artisan db:seed +``` + +Rolls back everything and migrates fresh. **Never use on production** — this drops data. Prefer `migrate:rollback` for targeted undo. + +### Revert a mistake + +```bash +php artisan modularity:migrate:rollback +``` + +Rolls back the **last batch** of migrations. Run repeatedly to roll back further batches. + +## Related + +- [Upgrading](/get-started/upgrading) — migrations play a central role in version upgrades +- [Sync / sync:states](../sync/sync-states) — keep enum-backed state columns in sync after migrations +- [check-collation](../check-collation) — verify database collation settings diff --git a/docs/src/pages/guide/commands/fix-module.md b/docs/src/pages/guide/console/module/fix-module.md similarity index 99% rename from docs/src/pages/guide/commands/fix-module.md rename to docs/src/pages/guide/console/module/fix-module.md index 7cd7ac47f..b8887f0be 100644 --- a/docs/src/pages/guide/commands/fix-module.md +++ b/docs/src/pages/guide/console/module/fix-module.md @@ -1,4 +1,4 @@ -# `Fix Module` +# Fix Module > Fixes the un-desired changes on module's config file diff --git a/docs/src/pages/guide/console/module/overview.md b/docs/src/pages/guide/console/module/overview.md new file mode 100644 index 000000000..4bf137449 --- /dev/null +++ b/docs/src/pages/guide/console/module/overview.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 11 +sidebarTitle: Overview +sidebarGroupTitle: Module +--- + +# Module Commands + +Manage the lifecycle of a module and the routes inside it. These commands correspond to the PHP classes under `src/Console/Module/` and cover creation fix-ups, removal, and per-route enable/disable controls. + +| Command | Signature | Description | +|---------|-----------|-------------| +| [fix-module](./fix-module) | `modularity:fix:module` | Patch a module's config file after scaffolding changes (add translation, media, file, position, slug, price, authorized, filepond, uuid, snapshot) | +| [remove-module](./remove-module) | `modularity:remove:module` | Completely remove a module — roll back its migrations and delete its files | +| [route:enable](./route-enable) | `modularity:route:enable` | Re-enable a previously disabled route within a module | +| [route:disable](./route-disable) | `modularity:route:disable` | Disable a single route without removing the module | +| [route:status](./route-status) | `modularity:route:status` | List route enable/disable status per module | + +## Common Workflows + +### Toggle a route off without losing its data + +```bash +php artisan modularity:route:disable Blog posts +php artisan modularity:route:status +``` + +Use [route:disable](./route-disable) to deactivate a route; its records and migrations stay intact. Re-enable with [route:enable](./route-enable) when ready. + +### Fix config drift after scaffolding + +When a generator adds a new feature (translation, media, file, etc.) but the module config hasn't been updated, run: + +```bash +php artisan modularity:fix:module Blog posts --addTranslation --addMedia +``` + +See [fix-module](./fix-module) for the full option list. + +### Completely remove a module + +```bash +php artisan modularity:remove:module Blog +``` + +::: danger Destructive +[remove-module](./remove-module) rolls back migrations and deletes the module directory. There is no undo. +::: + +## Related + +- [Generators](../generators/overview) — scaffold modules, models, routes, and traits +- [Database commands](../database/overview) — migrate / rollback used by `remove-module` +- [System Reference → Modules](/system-reference/modules) — module system internals diff --git a/docs/src/pages/guide/console/module/remove-module.md b/docs/src/pages/guide/console/module/remove-module.md new file mode 100644 index 000000000..1d6d8c79e --- /dev/null +++ b/docs/src/pages/guide/console/module/remove-module.md @@ -0,0 +1,40 @@ +--- +sidebarPos: 3 +sidebarTitle: Remove Module +--- + +# Remove Module + +> Completely remove a module — roll back its migrations and delete its files. + +## Command Information + +- **Signature:** `modularity:remove:module {module}` +- **Aliases:** `mod:r:module`, `unusual:remove:module`, `m:r:m` +- **Category:** Module + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name to remove (e.g. `Blog`) | + +## What It Does + +1. Disables the Modularous cache. +2. Rolls back all of the module's migrations (`modularity:migrate:rollback`). +3. Calls `Modularity::deleteModule()` to remove the module directory and unregister it. + +::: danger Destructive +This permanently deletes the module directory and its database tables. There is no undo. +::: + +## Example + +```bash +php artisan modularity:remove:module Blog +``` + +## Related + +- [route:disable](./route-disable) — disable a single route without removing the module diff --git a/docs/src/pages/guide/console/module/route-disable.md b/docs/src/pages/guide/console/module/route-disable.md new file mode 100644 index 000000000..39040a490 --- /dev/null +++ b/docs/src/pages/guide/console/module/route-disable.md @@ -0,0 +1,38 @@ +--- +sidebarPos: 4 +sidebarTitle: Route Disable +--- + +# Route Disable + +> Disable a specific route within a module without removing the module itself. + +## Command Information + +- **Signature:** `modularity:route:disable {module} {route}` +- **Category:** Module + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name (e.g. `Blog`) | +| `route` | Yes | The route name to disable (e.g. `posts`) | + +## What It Does + +Looks up the module, checks whether the route is currently enabled, and calls `$module->disableRoute($route)` to deactivate it. If the route is already disabled, a notice is printed but no error is thrown. + +Disabled routes are excluded from the router registration on the next request, effectively making them inaccessible without code changes. + +## Examples + +```bash +php artisan modularity:route:disable Blog posts +php artisan modularity:route:disable Shop api-products +``` + +## Related + +- [route:enable](./route-enable) — re-enable a disabled route +- [route:status](./route-status) — check which routes are enabled or disabled diff --git a/docs/src/pages/guide/console/module/route-enable.md b/docs/src/pages/guide/console/module/route-enable.md new file mode 100644 index 000000000..163f28278 --- /dev/null +++ b/docs/src/pages/guide/console/module/route-enable.md @@ -0,0 +1,38 @@ +--- +sidebarPos: 5 +sidebarTitle: Route Enable +--- + +# Route Enable + +> Re-enable a previously disabled route within a module. + +## Command Information + +- **Signature:** `modularity:route:enable {module} {route}` +- **Category:** Module + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `module` | Yes | The module name (e.g. `Blog`) | +| `route` | Yes | The route name to enable (e.g. `posts`) | + +## What It Does + +Looks up the module, checks whether the route is currently disabled, and calls `$module->enableRoute($route)` to activate it. If the route is already enabled, a notice is printed and no change is made. + +Once enabled, the route is registered with the router on the next request. + +## Examples + +```bash +php artisan modularity:route:enable Blog posts +php artisan modularity:route:enable Shop api-products +``` + +## Related + +- [route:disable](./route-disable) — disable a route +- [route:status](./route-status) — inspect route enable/disable state diff --git a/docs/src/pages/guide/commands/route-enable.md b/docs/src/pages/guide/console/module/route-status.md similarity index 63% rename from docs/src/pages/guide/commands/route-enable.md rename to docs/src/pages/guide/console/module/route-status.md index 14beaf77e..44c9f760f 100644 --- a/docs/src/pages/guide/commands/route-enable.md +++ b/docs/src/pages/guide/console/module/route-status.md @@ -1,50 +1,36 @@ -# `Route Enable` +# Route Status -> Enable the specified module route. +> List route enable/disable status per module. ## Command Information -- **Signature:** `modularity:route:enable <module> <route>` -- **Category:** Other - +- **Signature:** `modularity:route:status` +- **Category:** Module ## Examples -### With Arguments +### List all route statuses ```bash -php artisan modularity:route:enable MODULE ROUTE +php artisan modularity:route:status ``` - -`modularity:route:enable` +`modularity:route:status` ------------------------- -Enable the specified module route. - -### Usage - -* `modularity:route:enable <module> <route>` - -Enable the specified module route. - -### Arguments +Outputs a table showing every enabled module alongside each of its routes and whether that route is currently `enabled` or `disabled`. Modules with no tracked routes are shown with a `(no routes tracked)` note. -#### `module` +| Module | Route | Status | +|--------|-------|--------| +| Blog | posts | enabled | +| Blog | categories | disabled | +| Shop | products | enabled | -Module name. +Use [`modularity:route:enable`](/guide/console/module/route-enable) and [`modularity:route:disable`](/guide/console/module/route-disable) to change a route's status. -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `route` - -Route name. +### Usage -* Is required: yes -* Is array: no -* Default: `NULL` +* `modularity:route:status` ### Options @@ -116,4 +102,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/console/operations/overview.md b/docs/src/pages/guide/console/operations/overview.md new file mode 100644 index 000000000..b3b540e69 --- /dev/null +++ b/docs/src/pages/guide/console/operations/overview.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 12 +sidebarTitle: Overview +sidebarGroupTitle: Operations +--- + +# Operations Commands + +Operations commands manage the Modularous **Operations** pipeline — a queued job system for processing module operations (long-running tasks generated via `modularity:make:operation`). + +::: warning Internal commands +Both commands have `$hidden = true` and are not listed in `php artisan list`. They are intended for advanced use and internal tooling — in normal development you trigger operations from domain code, not the CLI. +::: + +| Command | Description | +|---------|-------------| +| [modularity:operations:process](/guide/console/operations/process-operations) | Dispatch or run pending module operations | +| [modularity:publish:operations](/guide/console/operations/publish-operations) | Publish the operations vendor assets | + +## When You Might Need These + +Normal flow: code dispatches an operation → Laravel queues it → a queue worker picks it up. These commands are escape hatches for when that flow is interrupted. + +| Situation | Command | Notes | +|-----------|---------|-------| +| Queue worker is down and pending operations are piling up | `modularity:operations:process` | One-shot run outside the queue | +| Testing / reproducing a stuck operation locally | `modularity:operations:process` | Run synchronously to see exceptions | +| Installing Modularous from source and assets haven't been published yet | `modularity:publish:operations` | Usually covered by `modularity:install` | + +## Related + +- [`modularity:make:operation`](../generators/make-operation) — scaffold a new operation class +- [Queue workers](https://laravel.com/docs/queues) — the default pipeline diff --git a/docs/src/pages/guide/console/operations/process-operations.md b/docs/src/pages/guide/console/operations/process-operations.md new file mode 100644 index 000000000..42d9dce3d --- /dev/null +++ b/docs/src/pages/guide/console/operations/process-operations.md @@ -0,0 +1,50 @@ +--- +sidebarPos: 2 +sidebarTitle: Operations Process +--- + +# Operations Process + +> Dispatch or execute pending module operations — synchronously, asynchronously, or via a named queue. + +::: warning Hidden command +This command has `$hidden = true` and does not appear in `php artisan list`. It is intended for advanced / internal use. +::: + +## Command Information + +- **Signature:** `modularity:operations:process [--s|sync] [--a|async] [--queue=] [--t|test] [--i|isolated] [--l|local]` +- **Alias:** `mod:operations:process` +- **Category:** Operations + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--sync` / `-s` | `false` | Run operations synchronously in the current process | +| `--async` / `-a` | `false` | Dispatch operations as queued jobs | +| `--queue` | — | Queue name to dispatch jobs onto | +| `--test` / `-t` | `false` | Dry-run — resolve operations but do not execute or dispatch | +| `--isolated` / `-i` | `false` | Prevent concurrent runs (uses mutex / atomic lock) | +| `--local` / `-l` | `false` | Only process operations defined in the local application | + +## What It Does + +Delegates to the underlying `operations:process` Artisan command with the options forwarded. When `--sync` is set operations run immediately in the current process. When `--async` is set each operation is pushed onto the queue (using `--queue` if supplied). Without either flag the command uses the default configured dispatch mode. + +## Examples + +```bash +# Run all pending operations synchronously +php artisan modularity:operations:process --sync + +# Dispatch operations onto the "low" queue +php artisan modularity:operations:process --async --queue=low + +# Dry-run to see what would be processed +php artisan modularity:operations:process --test +``` + +## Related + +- [modularity:publish:operations](/guide/console/operations/publish-operations) — publish vendor assets diff --git a/docs/src/pages/guide/console/operations/publish-operations.md b/docs/src/pages/guide/console/operations/publish-operations.md new file mode 100644 index 000000000..cbb92a101 --- /dev/null +++ b/docs/src/pages/guide/console/operations/publish-operations.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 3 +sidebarTitle: Operations Publish +--- + +# Operations Publish + +> Publish the Modularous operations vendor assets into the host application. + +::: warning Hidden command +This command has `$hidden = true` and does not appear in `php artisan list`. It is intended for advanced / internal use. +::: + +## Command Information + +- **Signature:** `modularity:publish:operations` +- **Category:** Operations + +## What It Does + +Runs `php artisan vendor:publish --tag=operations` to copy the operations configuration and migration stubs from the Modularous package into the host application's vendor publish paths. + +Run this once after installing or upgrading Modularous if your application uses the Operations pipeline. + +## Example + +```bash +php artisan modularity:publish:operations +``` + +## Related + +- [modularity:operations:process](/guide/console/operations/process-operations) — process pending operations diff --git a/docs/src/pages/guide/console/overview.md b/docs/src/pages/guide/console/overview.md new file mode 100644 index 000000000..9061608a2 --- /dev/null +++ b/docs/src/pages/guide/console/overview.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 1 +sidebarTitle: Console Overview +--- + +# Console Commands Overview + +Modularous provides Artisan commands for scaffolding, building, and managing modules. Commands are organized by category. + +## Categories + +| Category | Description | +|----------|-------------| +| [**Cache** →](/guide/console/cache/overview) | Clear, warm, inspect, and manage module caches | +| [**Coverage** →](/guide/console/coverage/overview) | Analyse Clover reports, generate reports, enforce thresholds, scaffold tests | +| [**Migration** →](/guide/console/migration/overview) | Run and rollback module migrations | +| [**Docs** →](/guide/console/docs/overview) | Audit and generate documentation | +| [**Flush** →](/guide/console/flush/overview) | Flush caches, FilePond uploads, and sessions | +| [**Generators** →](/guide/console/generators/overview) | Scaffold models, controllers, routes, hydrates, Vue inputs, tests | +| [**Make** →](/guide/console/make/overview) | Individual `make:*` command reference with examples and options | +| [**Setup** →](/guide/console/setup/overview) | Installation and development setup | +| [**Sync** →](/guide/console/sync/overview) | Sync model states and translation keys | +| [**Module** →](/guide/console/module/overview) | Fix, remove, and per-route enable/disable/status | +| [**Operations** →](/guide/console/operations/overview) | Process and publish the module operations pipeline _(internal)_ | +| [**Update** →](/guide/console/update/overview) | Patch host-application config files during upgrades _(internal)_ | + +## Quick Links + +- **Cache**: [cache:clear](/guide/console/cache/cache-clear), [cache:list](/guide/console/cache/cache-list), [cache:warm](/guide/console/cache/cache-warm), [cache:stats](/guide/console/cache/cache-stats), [cache:versions](/guide/console/cache/cache-versions), [cache:graph](/guide/console/cache/cache-graph) +- **Coverage**: [coverage:analyze](/guide/console/coverage/coverage-analyze), [coverage:pr:check](/guide/console/coverage/coverage-pr-check), [coverage:report](/guide/console/coverage/coverage-report), [coverage:generate-tests](/guide/console/coverage/coverage-generate-tests), [coverage:watch](/guide/console/coverage/coverage-watch) +- **Migration**: [migrate](/guide/console/migration/migrate), [migrate:refresh](/guide/console/migration/migrate-refresh), [migrate:rollback](/guide/console/migration/migrate-rollback) +- **Docs**: [docs:audit](/guide/console/docs/docs-audit), [generate:command:docs](/guide/console/docs/generate-command-docs) +- **Flush**: [flush](/guide/console/flush/flush), [flush:filepond](/guide/console/flush/flush-filepond), [flush:sessions](/guide/console/flush/flush-sessions) +- **Make**: [make:module](/guide/console/make/module), [make:route](/guide/console/make/route), [make:model](/guide/console/make/model), [make:migration](/guide/console/make/migration), [make:repository](/guide/console/make/repository), [make:controller](/guide/console/make/controller), [make:controller:api](/guide/console/make/controller-api), [make:controller:front](/guide/console/make/controller-front), [make:request](/guide/console/make/request), [make:event](/guide/console/make/event), [make:listener](/guide/console/make/listener), [make:operation](/guide/console/make/operation), [make:horizon:supervisor](/guide/console/make/horizon-supervisor), [make:stubs](/guide/console/make/stubs), [make:command](/guide/console/make/command), [make:model:trait](/guide/console/make/model-trait), [make:repository:trait](/guide/console/make/repository-trait), [make:route:permissions](/guide/console/make/route-permissions), [make:theme:folder](/guide/console/make/theme-folder), [make:theme](/guide/console/make/theme), [make:input:hydrate](/guide/console/make/input-hydrate), [make:vue:input](/guide/console/make/vue-input), [make:vue:test](/guide/console/make/vue-test), [make:laravel:test](/guide/console/make/laravel-test), [make:feature](/guide/console/make/feature) +- **Generators**: [make:module](/guide/console/generators/make-module), [make:model](/guide/console/generators/make-model), [make:controller](/guide/console/generators/make-controller), [make:controller-api](/guide/console/generators/make-controller-api), [make:controller-front](/guide/console/generators/make-controller-front), [make:route](/guide/console/generators/make-route), [make:migration](/guide/console/generators/make-migration), [make:repository](/guide/console/generators/make-repository), [make:request](/guide/console/generators/make-request), [make:event](/guide/console/generators/make-event), [make:listener](/guide/console/generators/make-listener), [make:operation](/guide/console/generators/make-operation), [make:horizon-supervisor](/guide/console/generators/make-horizon-supervisor), [make:theme](/guide/console/generators/make-theme), [make:stubs](/guide/console/generators/make-stubs), [create:command](/guide/console/generators/create-command), [create:feature](/guide/console/generators/create-feature), [create:input-hydrate](/guide/console/generators/create-input-hydrate), [create:vue-input](/guide/console/generators/create-vue-input), [create:model-trait](/guide/console/generators/create-model-trait), [create:repository-trait](/guide/console/generators/create-repository-trait), [create:route-permissions](/guide/console/generators/create-route-permissions), [create:theme](/guide/console/generators/create-theme), [create:test-laravel](/guide/console/generators/create-test-laravel), [create:vue-test](/guide/console/generators/create-vue-test) +- **Setup**: [install](/guide/console/setup/install), [setup:development](/guide/console/setup/setup-development), [create:database](/guide/console/setup/create-database), [create:superadmin](/guide/console/generators/create-superadmin) +- **Sync**: [sync:states](/guide/console/sync/sync-states), [sync:translations](/guide/console/sync/sync-translations) +- **Module**: [route:enable](/guide/console/module/route-enable), [route:disable](/guide/console/module/route-disable), [route:status](/guide/console/module/route-status), [fix-module](/guide/console/module/fix-module), [remove-module](/guide/console/module/remove-module) +- **Other**: [check-collation](/guide/console/check-collation), [refresh](/guide/console/refresh), [get-version](/guide/console/get-version), [pint](/guide/console/pint), [replace-regex](/guide/console/replace-regex) +- **Operations**: [operations:process](/guide/console/operations/process-operations), [publish:operations](/guide/console/operations/publish-operations) +- **Update**: [update:laravel:configs](/guide/console/update/update-laravel-configs) + +See [Backend](/system-reference/backend/overview#console-commands) for a full command list. diff --git a/docs/src/pages/guide/commands/Generators/make-stubs.md b/docs/src/pages/guide/console/pint.md similarity index 56% rename from docs/src/pages/guide/commands/Generators/make-stubs.md rename to docs/src/pages/guide/console/pint.md index 209bb360f..9af3966ea 100644 --- a/docs/src/pages/guide/commands/Generators/make-stubs.md +++ b/docs/src/pages/guide/console/pint.md @@ -1,103 +1,72 @@ -# `Make Stubs` +# Pint -> Create stub files for route. +> Format code with Laravel Pint for the specified targets. ## Command Information -- **Signature:** `modularity:make:stubs [--only [ONLY]] [--except [EXCEPT]] [-f|--force] [--fix] [--] <module> <route>` -- **Category:** Generators - +- **Signature:** `modularity:pint [--test] [--dirty] [--repair] [-s|--self]` +- **Category:** Other ## Examples -### With Arguments +### Format all module files ```bash -php artisan modularity:make:stubs MODULE ROUTE +php artisan modularity:pint ``` -### With Options - -```bash -php artisan modularity:make:stubs --only=ONLY -``` +### Check for formatting issues without fixing them ```bash -php artisan modularity:make:stubs --except=EXCEPT +php artisan modularity:pint --test ``` -```bash -# Using shortcut -php artisan modularity:make:stubs -f - -# Using full option name -php artisan modularity:make:stubs --force -``` +### Format only files modified since the last commit ```bash -php artisan modularity:make:stubs --fix +php artisan modularity:pint --dirty ``` -### Common Combinations +### Format the Modularous package source (dev only) ```bash -php artisan modularity:make:stubs MODULE +php artisan modularity:pint --self ``` -`modularity:make:stubs` ------------------------ +`modularity:pint` +----------------- -Create stub files for route. +Runs `./vendor/bin/pint` against the modules directory (read from `config('modules.paths.modules')`). With `--self`, it targets the Modularous package source using the package's own `pint.json` config — this is only available in non-production environments. ### Usage -* `modularity:make:stubs [--only [ONLY]] [--except [EXCEPT]] [-f|--force] [--fix] [--] <module> <route>` - -Create stub files for route. - -### Arguments - -#### `module` - -The name of module will be used. - -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `route` - -The name of the route. - -* Is required: yes -* Is array: no -* Default: `NULL` +* `modularity:pint [--test] [--dirty] [--repair] [-s|--self]` ### Options -#### `--only` +#### `--test` -get only stubs +Check files for formatting issues without making any changes. Exits with a non-zero code if issues are found. -* Accept value: yes +* Accept value: no * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` +* Default: `false` -#### `--except` +#### `--dirty` -get except stubs +Only format files that have been modified (not yet committed). -* Accept value: yes +* Accept value: no * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` +* Default: `false` -#### `--force|-f` +#### `--repair` -Force the operation to run when the route files already exist. +Run Pint in repair mode. * Accept value: no * Is value required: no @@ -105,9 +74,9 @@ Force the operation to run when the route files already exist. * Is negatable: no * Default: `false` -#### `--fix` +#### `--self|-s` -Fixes the model config errors +Lint the Modularous package source using the package's own `pint.json`. Not allowed in production. * Accept value: no * Is value required: no @@ -183,4 +152,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/console/refresh.md b/docs/src/pages/guide/console/refresh.md new file mode 100644 index 000000000..506a923f1 --- /dev/null +++ b/docs/src/pages/guide/console/refresh.md @@ -0,0 +1,32 @@ +--- +sidebarPos: 17 +sidebarTitle: Refresh +--- + +# Refresh + +> Republish Modularous frontend assets to `public/vendor/modularity` and clear view/application caches. + +## Command Information + +- **Signature:** `modularity:refresh` +- **Category:** Module + +## What It Does + +1. Deletes `public/vendor/modularity` entirely. +2. Runs `vendor:publish --provider=LaravelServiceProvider --tag=modularity-assets --force` to copy fresh assets. +3. Calls `cache:clear` and `view:clear`. + +Run this after upgrading the Modularous package to ensure the browser receives the updated JS/CSS files. + +## Example + +```bash +php artisan modularity:refresh +``` + +## Related + +- [build](./assets/build) — rebuild custom Vue assets +- [get:version](./get-version) — confirm the installed Modularous version diff --git a/docs/src/pages/guide/console/replace-regex.md b/docs/src/pages/guide/console/replace-regex.md new file mode 100644 index 000000000..6dda24c6b --- /dev/null +++ b/docs/src/pages/guide/console/replace-regex.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 18 +sidebarTitle: Replace Regex +--- + +# Replace Regex + +> Recursively apply a regex find-and-replace across all files in a directory. + +::: warning Hidden command +This command has `$hidden = true` and does not appear in `php artisan list`. +::: + +## Command Information + +- **Signature:** `modularity:replace:regex {path} {pattern} {data} [--d|directory=] [--p|pretend]` +- **Alias:** `mod:replace:regex` +- **Category:** Module + +## Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `path` | Yes | Root directory to walk (absolute or relative to `base_path()`) | +| `pattern` | Yes | PCRE regex pattern to search for (without delimiters — `/` is added automatically) | +| `data` | Yes | Replacement string (supports `$1` back-references) | + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--directory=` / `-d` | `**/*.php` | Glob pattern to filter which files are processed | +| `--pretend` / `-p` | `false` | Preview matched files and diffs without writing any changes | + +## What It Does + +Delegates to `RegexReplacement::run()`. Files inside `vendor/` or `node_modules/` are skipped unless `path` itself points inside those directories. An invalid regex or non-existent path causes an early error exit. + +## Examples + +```bash +# Preview matches without writing +php artisan modularity:replace:regex app/Modules "OldNamespace" "NewNamespace" --pretend + +# Replace across all PHP files +php artisan modularity:replace:regex app/Modules "OldNamespace" "NewNamespace" + +# Restrict to a specific glob pattern +php artisan modularity:replace:regex app "Foo\\\\Bar" "Baz\\\\Qux" --directory="**/*Controller.php" +``` + +## Related + +- [RegexReplacement](/system-reference/backend/support/regex-replacement) — the underlying support class diff --git a/docs/src/pages/guide/commands/Generators/make-controller-front.md b/docs/src/pages/guide/console/setup/create-database.md similarity index 51% rename from docs/src/pages/guide/commands/Generators/make-controller-front.md rename to docs/src/pages/guide/console/setup/create-database.md index 5ed229ab4..3617cbe5e 100644 --- a/docs/src/pages/guide/commands/Generators/make-controller-front.md +++ b/docs/src/pages/guide/console/setup/create-database.md @@ -1,67 +1,50 @@ -# `Make Controller Front` +# Create Database -> Create Front Controller with repository for specified module. +> Create the database if it does not exist. ## Command Information -- **Signature:** `modularity:make:controller:front [--example [EXAMPLE]] [--] <module> <name>` -- **Category:** Generators - +- **Signature:** `modularity:create:database [--connection[=CONNECTION]]` +- **Category:** Setup ## Examples -### With Arguments +### Create the default database ```bash -php artisan modularity:make:controller:front MODULE NAME +php artisan modularity:create:database ``` -### With Options +### Create a database on a specific connection ```bash -php artisan modularity:make:controller:front --example=EXAMPLE +php artisan modularity:create:database --connection=mysql +php artisan modularity:create:database --connection=pgsql ``` -### Common Combinations +`modularity:create:database` +----------------------------- -```bash -php artisan modularity:make:controller:front MODULE -``` +Creates the database defined in the given connection config if it does not already exist. The database name, host, port, charset, and collation are all read from `config('database.connections.<connection>')`. Useful in CI pipelines and initial setup scripts where the database may not yet exist. -`modularity:make:controller:front` ----------------------------------- +**Supported drivers:** -Create Front Controller with repository for specified module. +| Driver | Behaviour | +|--------|-----------| +| `mysql` | Runs `CREATE DATABASE IF NOT EXISTS` with charset and collation from config (defaults: `utf8mb4` / `utf8mb4_unicode_ci`) | +| `pgsql` | Creates the database if it does not appear in `pg_database` | +| `sqlite` | Touches the file at the configured path, creating parent directories as needed | +| `sqlsrv` | Runs `CREATE DATABASE` if the name is not in `sys.databases` | ### Usage -* `modularity:make:controller:front [--example [EXAMPLE]] [--] <module> <name>` - -Create Front Controller with repository for specified module. - -### Arguments - -#### `module` - -The name of module will be used. - -* Is required: yes -* Is array: no -* Default: `NULL` - -#### `name` - -The name of the controller class. - -* Is required: yes -* Is array: no -* Default: `NULL` +* `modularity:create:database [--connection[=CONNECTION]]` ### Options -#### `--example` +#### `--connection` -An example option. +The database connection to use. Reads from `config('database.default')` when omitted. * Accept value: yes * Is value required: no @@ -137,4 +120,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/commands/Setup/install.md b/docs/src/pages/guide/console/setup/install.md similarity index 96% rename from docs/src/pages/guide/commands/Setup/install.md rename to docs/src/pages/guide/console/setup/install.md index e4663a959..ba501ad1d 100644 --- a/docs/src/pages/guide/commands/Setup/install.md +++ b/docs/src/pages/guide/console/setup/install.md @@ -1,6 +1,6 @@ -# `Install` +# Install -> Install unusual-modularity into your Laravel application +> Install unusual-modularous into your Laravel application ## Command Information @@ -110,13 +110,13 @@ php artisan modularity:install --addSnapshot `modularity:install` -------------------- -Install unusual-modularity into your Laravel application +Install unusual-modularous into your Laravel application ### Usage * `modularity:install [-d|--default] [-db|--db-process] [-T|--addTranslation] [-M|--addMedia] [-F|--addFile] [-P|--addPosition] [-S|--addSlug] [--addPrice] [-A|--addAuthorized] [-FP|--addFilepond] [--addUuid] [-SS|--addSnapshot]` -Install unusual-modularity into your Laravel application +Install unusual-modularous into your Laravel application ### Options @@ -308,4 +308,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/console/setup/overview.md b/docs/src/pages/guide/console/setup/overview.md new file mode 100644 index 000000000..9a53f0b88 --- /dev/null +++ b/docs/src/pages/guide/console/setup/overview.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 13 +sidebarTitle: Overview +sidebarGroupTitle: Setup +--- + +# Setup Commands + +Installation and initial configuration commands. Run these once when setting up a new project or developer environment. + +| Command | Signature | Description | +|---------|-----------|-------------| +| [install](./install) | `modularity:install` | Full Modularous installation (publishes config, runs migrations, sets up auth) | +| [setup:development](./setup-development) | `modularity:setup:development` | Configure a local dev environment (symlinks, env, permissions) | +| [create:database](./create-database) | `modularity:create:database` | Create the application database if it does not exist | +| [create:superadmin](../generators/create-superadmin) | `modularity:create:superadmin` | Create the initial superadmin user account | + +::: tip +Run `modularity:install` first, then `modularity:create:superadmin` to bootstrap a fresh project. +::: + +## Common Workflows + +### First install on a fresh project + +```bash +composer require unusualify/modularous +php artisan modularity:create:database # optional — only if the DB doesn't exist yet +php artisan modularity:install # publishes config + runs migrations + sets up auth +php artisan modularity:create:superadmin # creates the first admin user +php artisan modularity:build # build frontend assets +``` + +### Bootstrapping a dev machine after a fresh clone + +```bash +composer install +npm install +cp .env.example .env +php artisan key:generate +php artisan modularity:setup:development # symlinks, storage perms, optional seeders +php artisan modularity:migrate +php artisan modularity:build +``` + +### Re-running install safely + +`modularity:install` is **idempotent** — it detects existing config and migrations and skips them. Re-run it when you adopt Modularous in an existing Laravel app, or after upgrading to pick up new publishable assets. + +## Related + +- [Installation Guide](/get-started/installation-guide) — the full first-time setup tutorial +- [Upgrading](/get-started/upgrading) — what to run when moving between versions +- [Update commands](../update/overview) — config patchers invoked during install / upgrade diff --git a/docs/src/pages/guide/commands/Setup/setup-development.md b/docs/src/pages/guide/console/setup/setup-development.md similarity index 91% rename from docs/src/pages/guide/commands/Setup/setup-development.md rename to docs/src/pages/guide/console/setup/setup-development.md index 4e9fe12e7..0de784a2e 100644 --- a/docs/src/pages/guide/commands/Setup/setup-development.md +++ b/docs/src/pages/guide/console/setup/setup-development.md @@ -1,6 +1,6 @@ -# `Setup Development` +# Setup Development -> Setup modularity development on local +> Setup modularous development on local ## Command Information @@ -20,13 +20,13 @@ php artisan modularity:setup:development BRANCH `modularity:setup:development` ------------------------------ -Setup modularity development on local +Setup modularous development on local ### Usage * `modularity:setup:development [<branch>]` -Setup modularity development on local +Setup modularous development on local ### Arguments @@ -108,4 +108,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/console/sync/overview.md b/docs/src/pages/guide/console/sync/overview.md new file mode 100644 index 000000000..3501d781a --- /dev/null +++ b/docs/src/pages/guide/console/sync/overview.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 14 +sidebarTitle: Overview +sidebarGroupTitle: Sync +--- + +# Sync Commands + +Synchronise runtime state that drifts over time — model states and translation keys. These are **safe, idempotent** maintenance commands: re-running them won't duplicate data, only reconcile what's missing. + +| Command | Signature | Description | +|---------|-----------|-------------| +| [sync:states](./sync-states) | `modularity:sync:states` | Sync model state values to their current definitions | +| [sync:translations](./sync-translations) | `modularity:sync:translations` | Sync translation keys across all registered locales | + +## Common Workflows + +### After editing state definitions + +When you add a new value to a `HasStateable` model's state enum or rename an existing state: + +```bash +php artisan modularity:sync:states +``` + +Adds missing state rows, preserves existing records' state assignments, and leaves historical state transitions intact. See [HasStateable](/system-reference/backend/entity-traits/overview) for the trait itself. + +### After adding translation keys or locales + +```bash +php artisan modularity:sync:translations +``` + +Walks every translated model / attribute and creates missing translation rows for each registered locale (e.g. when you added `de` after `en` was already populated). Existing translations are left untouched. + +### On deploy + +Add both to your deploy script to cover schema changes that introduced new states or enabled a new locale: + +```bash +php artisan modularity:migrate +php artisan modularity:sync:states +php artisan modularity:sync:translations +``` + +## Related + +- [Module Features](/guide/module-features/overview) — includes `HasStateable` and `HasTranslations` +- [Database commands](../database/overview) — the prerequisite for sync diff --git a/docs/src/pages/guide/commands/Generators/create-route-permissions.md b/docs/src/pages/guide/console/sync/sync-states.md similarity index 60% rename from docs/src/pages/guide/commands/Generators/create-route-permissions.md rename to docs/src/pages/guide/console/sync/sync-states.md index 7312d9780..8d9f8ed3f 100644 --- a/docs/src/pages/guide/commands/Generators/create-route-permissions.md +++ b/docs/src/pages/guide/console/sync/sync-states.md @@ -1,67 +1,52 @@ -# `Make Route Permissions` +--- +sidebarPos: 2 +sidebarTitle: Sync States +--- -> Create permissions for routes +# Sync States -## Command Information +> Sync a stateable model's states. -- **Signature:** `modularity:make:route:permissions [--route [ROUTE]] [--] <route>` -- **Alias:** `modularity:make:route:permissions` (deprecated, use `make:route:permissions`) -- **Category:** Generators +## Command Information +- **Signature:** `modularity:sync:states [<model>]` +- **Category:** Sync ## Examples -### With Arguments - -```bash -php artisan modularity:make:route:permissions ROUTE -``` - -### With Options +### Interactive — select a model from a prompt ```bash -php artisan modularity:make:route:permissions --route=ROUTE +php artisan modularity:sync:states ``` -### Common Combinations +### Sync states for a specific model class ```bash -php artisan modularity:make:route:permissions ROUTE +php artisan modularity:sync:states "App\Models\Order" ``` -`modularity:make:route:permissions` -------------------------------------- +`modularity:sync:states` +------------------------ -Create permissions for routes +Finds all models that use the `HasStateable` trait and syncs their state definitions to the database. When no `model` argument is given, an interactive prompt lets you pick from all discovered stateable models. New states are created and reported; existing states are left unchanged. ### Usage -* `modularity:make:route:permissions [--route [ROUTE]] [--] <route>` - -Create permissions for routes +* `modularity:sync:states [<model>]` ### Arguments -#### `route` +#### `model` -The name of the route. +Fully qualified class name of the model to sync states for. If omitted, an interactive prompt is shown listing all models that use `HasStateable`. -* Is required: yes +* Is required: no * Is array: no * Default: `NULL` ### Options -#### `--route` - -The validation rules. - -* Accept value: yes -* Is value required: no -* Is multiple: no -* Is negatable: no -* Default: `NULL` - #### `--help|-h` Display help for the given command. When no command is given display help for the list command @@ -130,4 +115,4 @@ The environment the command should run under * Is value required: no * Is multiple: no * Is negatable: no -* Default: `NULL` \ No newline at end of file +* Default: `NULL` diff --git a/docs/src/pages/guide/console/sync/sync-translations.md b/docs/src/pages/guide/console/sync/sync-translations.md new file mode 100644 index 000000000..6d5f89941 --- /dev/null +++ b/docs/src/pages/guide/console/sync/sync-translations.md @@ -0,0 +1,166 @@ +--- +sidebarPos: 3 +sidebarTitle: Sync Translations +--- + +# Sync Translations + +> Sync missing translation keys from the Laravel lang path to the Modularous lang path. + +## Command Information + +- **Signature:** `modularity:sync:translations [--dry-run] [--only-languages[=ONLY-LANGUAGES]] [--exclude-languages[=EXCLUDE-LANGUAGES]] [--language[=LANGUAGE]]` +- **Category:** Sync + +## Examples + +### Sync all missing keys for all languages + +```bash +php artisan modularity:sync:translations +``` + +### Preview missing keys without writing any files + +```bash +php artisan modularity:sync:translations --dry-run +``` + +### Sync only a specific language + +```bash +php artisan modularity:sync:translations --language=tr +``` + +### Sync only specific languages (comma-separated) + +```bash +php artisan modularity:sync:translations --only-languages=en,tr,de +``` + +### Sync all languages except specific ones + +```bash +php artisan modularity:sync:translations --exclude-languages=fr,es +``` + +`modularity:sync:translations` +------------------------------- + +Compares translation files in `lang/` (Laravel's lang path) against `modularity/lang/` and copies any missing keys into the Modularous lang path. Language folders that do not yet exist in `modularity/lang/` are created automatically. Use `--dry-run` to inspect what would be synced without modifying any files. + +### Usage + +* `modularity:sync:translations [--dry-run] [--only-languages[=ONLY-LANGUAGES]] [--exclude-languages[=EXCLUDE-LANGUAGES]] [--language[=LANGUAGE]]` + +### Options + +#### `--dry-run` + +Show missing keys without writing any files. + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--language` + +Sync only a single specific language (e.g. `--language=tr`). + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` + +#### `--only-languages` + +Comma-separated list of languages to sync (e.g. `--only-languages=en,tr`). + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` + +#### `--exclude-languages` + +Comma-separated list of languages to skip (e.g. `--exclude-languages=fr,es`). + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` + +#### `--help|-h` + +Display help for the given command. When no command is given display help for the list command + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--quiet|-q` + +Do not output any message + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--verbose|-v|-vv|-vvv` + +Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--version|-V` + +Display this application version + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--ansi|--no-ansi` + +Force (or disable --no-ansi) ANSI output + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: yes +* Default: `NULL` + +#### `--no-interaction|-n` + +Do not ask any interactive question + +* Accept value: no +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `false` + +#### `--env` + +The environment the command should run under + +* Accept value: yes +* Is value required: no +* Is multiple: no +* Is negatable: no +* Default: `NULL` diff --git a/docs/src/pages/guide/console/update/overview.md b/docs/src/pages/guide/console/update/overview.md new file mode 100644 index 000000000..3a9aefc57 --- /dev/null +++ b/docs/src/pages/guide/console/update/overview.md @@ -0,0 +1,32 @@ +--- +sidebarPos: 15 +sidebarTitle: Overview +sidebarGroupTitle: Update +--- + +# Update Commands + +Update commands patch host-application configuration files to align with Modularous requirements during installation or version upgrades. They perform **surgical, reversible edits** to keep your `config/*.php` files compatible — they don't overwrite your customisations. + +::: warning Internal commands +All Update commands have `$hidden = true` and are not listed in `php artisan list`. They are invoked automatically by `modularity:install` and by the upgrade guide, but you can run them manually when something drifts. +::: + +| Command | Description | +|---------|-------------| +| [modularity:update:laravel:configs](/guide/console/update/update-laravel-configs) | Patch `config/auth.php` and optional config files for Modularous guards | + +## When to Run These Manually + +| Symptom | Likely fix | +|---------|------------| +| Login page rejects valid credentials with "guard not defined" | `php artisan modularity:update:laravel:configs` | +| Fresh clone missing Modularous guards in `config/auth.php` | `php artisan modularity:update:laravel:configs` | +| Upgraded Modularous and release notes mention new guards / providers | `php artisan modularity:update:laravel:configs` | + +Always diff `config/auth.php` against git before committing after running an Update command — the patch is transparent, but you should understand what changed. + +## Related + +- [Upgrading](/get-started/upgrading) — the primary context for Update commands +- [Setup / install](../setup/install) — invokes Update commands automatically on first install diff --git a/docs/src/pages/guide/console/update/update-laravel-configs.md b/docs/src/pages/guide/console/update/update-laravel-configs.md new file mode 100644 index 000000000..b3820a377 --- /dev/null +++ b/docs/src/pages/guide/console/update/update-laravel-configs.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 2 +sidebarTitle: Update Laravel Configs +--- + +# Update Laravel Configs + +> Patch host-application config files with the Modularous authentication guards, providers, and optional module/translation settings. + +::: warning Hidden command +This command has `$hidden = true` and does not appear in `php artisan list`. It is run automatically during `modularity:install` and when upgrading between major versions. +::: + +## Command Information + +- **Signature:** `modularity:update:laravel:configs` +- **Category:** Update + +## What It Does + +The command applies a series of targeted patches to the host application's published config files: + +1. **`config/auth.php`** — adds the Modularous guard and user provider definitions so that `Auth::guard('modularity')` resolves correctly. +2. **`config/modules.php`** _(if present)_ — merges Modularous module discovery paths. +3. **`config/translation.php`** _(if present)_ — registers Modularous translation file groups. + +All patches are idempotent — running the command twice will not duplicate entries. + +## Example + +```bash +php artisan modularity:update:laravel:configs +``` + +This is typically run as part of the post-install / post-update workflow: + +```bash +php artisan modularity:install +# or, after a composer update: +php artisan modularity:update:laravel:configs +``` + +## Related + +- [Setup commands](/guide/console/setup/overview) — full installation workflow diff --git a/docs/src/pages/guide/custom-auth-pages/attributes.md b/docs/src/pages/guide/custom-auth-pages/attributes.md index e7759776b..98f78eb7d 100644 --- a/docs/src/pages/guide/custom-auth-pages/attributes.md +++ b/docs/src/pages/guide/custom-auth-pages/attributes.md @@ -1,5 +1,5 @@ --- -sidebarPos: 3 +sidebarPos: 2 sidebarTitle: Attributes & Custom Props --- diff --git a/docs/src/pages/guide/custom-auth-pages/configuration.md b/docs/src/pages/guide/custom-auth-pages/configuration.md index d41ecc446..b661c588c 100644 --- a/docs/src/pages/guide/custom-auth-pages/configuration.md +++ b/docs/src/pages/guide/custom-auth-pages/configuration.md @@ -1,5 +1,5 @@ --- -sidebarPos: 2 +sidebarPos: 3 sidebarTitle: Configuration --- diff --git a/docs/src/pages/guide/custom-auth-pages/index.md b/docs/src/pages/guide/custom-auth-pages/index.md index 86d389173..d2533ae2f 100644 --- a/docs/src/pages/guide/custom-auth-pages/index.md +++ b/docs/src/pages/guide/custom-auth-pages/index.md @@ -1,11 +1,11 @@ --- -sidebarPos: 1 +sidebarPos: 5 sidebarTitle: Custom Auth Pages --- # Custom Auth Pages -Modularity provides a flexible authentication system that you can fully customize without modifying package code. All auth pages (login, register, forgot password, etc.) are driven by configuration files. +Modularous provides a flexible authentication system that you can fully customize without modifying package code. All auth pages (login, register, forgot password, etc.) are driven by configuration files. ## Overview diff --git a/docs/src/pages/guide/custom-auth-pages/layout-presets.md b/docs/src/pages/guide/custom-auth-pages/layout-presets.md index a2fb2dab6..c218e56f0 100644 --- a/docs/src/pages/guide/custom-auth-pages/layout-presets.md +++ b/docs/src/pages/guide/custom-auth-pages/layout-presets.md @@ -1,5 +1,5 @@ --- -sidebarPos: 5 +sidebarPos: 6 sidebarTitle: Layout Presets --- diff --git a/docs/src/pages/guide/custom-auth-pages/page-definitions.md b/docs/src/pages/guide/custom-auth-pages/page-definitions.md index 9fb665397..2024dd488 100644 --- a/docs/src/pages/guide/custom-auth-pages/page-definitions.md +++ b/docs/src/pages/guide/custom-auth-pages/page-definitions.md @@ -1,5 +1,5 @@ --- -sidebarPos: 6 +sidebarPos: 7 sidebarTitle: Page Definitions --- diff --git a/docs/src/pages/guide/form-inputs/checkbox-card.md b/docs/src/pages/guide/form-inputs/checkbox-card.md new file mode 100644 index 000000000..dda198b9f --- /dev/null +++ b/docs/src/pages/guide/form-inputs/checkbox-card.md @@ -0,0 +1,56 @@ +--- +sidebarTitle: Checkbox Card +sidebarPos: 7 +--- + +# Checkbox Card + +`VInputCheckboxCard` is a card-style selectable item used for multi-select UIs. Clicking the card toggles the value in/out of the `modelValue` array. There is no corresponding PHP hydrate — this component is used directly in Vue templates. + +## Vue Component + +**Registered as:** `VInputCheckboxCard` +**File:** `vue/src/js/components/inputs/CheckboxCard.vue` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Array` | — | Array of currently selected values (v-model) | +| `value` | `String \| Number` | `null` | The value this card represents; toggled in/out of `modelValue` | +| `title` | `String` | *(required)* | Card heading text | +| `description` | `String` | `''` | Optional body text below the title | +| `stats` | `Array` | `null` | Array of `{ label, value, color? }` stat blocks shown at the bottom | +| `disabled` | `Boolean` | `false` | Disables click and dims the card | +| `readonly` | `Boolean` | `false` | Prevents value changes without dimming | +| `checkboxColor` | `String` | `'primary'` | Vuetify color of the embedded checkbox | +| `activeColor` | `String` | `null` | Card background color when selected | +| `activeTitleColor` | `String` | `null` | Title text color when selected | +| `checkboxOnRight` | `Boolean` | `false` | Places the checkbox in the card's append slot instead of prepend | + +### Usage + +```vue +<VInputCheckboxCard + v-model="selectedPlans" + :value="plan.id" + :title="plan.name" + :description="plan.description" + :stats="[ + { label: 'Users', value: plan.users, color: 'primary' }, + { label: 'Storage', value: plan.storage, color: 'success' }, + ]" + checkboxColor="success" +/> +``` + +### Behaviour + +- The component operates on an **array** `modelValue`. Clicking toggles `value` in or out of that array using `Array.includes` / `Array.filter`. +- When `value` is in `modelValue` the card switches to `elevated` variant and applies `border-primary`; otherwise it uses `outlined` with `border-grey-lighten-4`. +- `stats` renders a responsive grid of metric blocks inside a `v-card-text` at the bottom of the card. Each stat takes equal column width. + +## See Also + +- [Checkbox](/guide/form-inputs/input-checkbox) — Simple boolean toggle (single value) +- [Forms overview](/guide/form-inputs/overview) — Schema-driven form architecture diff --git a/docs/src/pages/guide/form-inputs/credit-card-form.md b/docs/src/pages/guide/form-inputs/credit-card-form.md new file mode 100644 index 000000000..e4ee10b61 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/credit-card-form.md @@ -0,0 +1,94 @@ +--- +sidebarTitle: Credit Card Form +sidebarPos: 12 +--- + +# Credit Card Form + +`CreditCardForm` is a complete payment card entry UI: a live animated card preview (`CreditCard`) paired with a form for card number, holder name, expiry date, and CVV. There is no corresponding PHP hydrate — these components are used directly in Vue templates, typically inside the [Payment Service](/guide/form-inputs/input-payment-service) flow. + +## Vue Component — CreditCardForm + +**File:** `vue/src/js/components/inputs/CreditCardForm.vue` + +The form injects `submitForm` from its parent context via `inject('submitForm')` — the parent is responsible for providing the submit handler. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `formData` | `Object` | `{ cardName, cardNumber, cardNumberNotMask, cardMonth, cardYear, cardCvv }` | Reactive form data object; mutated directly by the form | +| `backgroundImage` | `String \| Object` | — | Custom card background image URL | +| `randomBackgrounds` | `Boolean` | `true` | Randomly pick a background from the built-in image set | +| `inputDensity` | `String` | `'default'` | Vuetify density for all text fields (`'compact'`, `'comfortable'`, `'default'`) | + +### Emits + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:cardName` | `String` | Card holder name changed | +| `update:cardNumber` | `String` | Card number changed (masked) | +| `update:cardMonth` | `String` | Expiry month changed | +| `update:cardYear` | `Number` | Expiry year changed | +| `update:cardCvv` | `String` | CVV changed | + +### Usage + +```vue +<script setup> +import { reactive, provide } from 'vue' + +const formData = reactive({ + cardName: '', + cardNumber: '', + cardNumberNotMask: '', + cardMonth: '', + cardYear: '', + cardCvv: '', +}) + +provide('submitForm', () => { + // handle payment submission +}) +</script> + +<template> + <CreditCardForm + :formData="formData" + :randomBackgrounds="true" + inputDensity="comfortable" + /> +</template> +``` + +### Behaviour + +- The card number is **auto-masked** (middle digits replaced with `*`) when the field loses focus. +- Card type (Visa, Mastercard, Amex, etc.) is detected from the card number prefix and the corresponding logo is shown on the card preview. +- The expiry year dropdown generates 12 years from the current year. If the selected year equals the current year, past months are disabled. +- The PAY button calls the injected `submitForm` function. + +--- + +## Sub-component — CreditCard + +**File:** `vue/src/js/components/inputs/CreditCard.vue` + +Pure display component that renders the animated 3D card. Used internally by `CreditCardForm`; can also be used standalone. + +### Props + +| Prop | Type | Description | +|------|------|-------------| +| `labels` | `Object` | `{ cardNumber, cardName, cardMonth, cardYear, cardCvv }` — current display values | +| `fields` | `Object` | `{ cardNumber, cardName, cardMonth, cardYear, cardCvv }` — element IDs for focus tracking | +| `isCardNumberMasked` | `Boolean` | Whether to mask middle digits of the card number | +| `randomBackgrounds` | `Boolean` | Randomly select a background image | +| `backgroundImage` | `String \| Object` | Custom background override | + +The card automatically flips to show the CVV side when the CVV field has focus. + +## See Also + +- [Payment Service](/guide/form-inputs/input-payment-service) — Full payment method selector that uses this form +- [Price](/guide/form-inputs/input-price) — Numeric price input with currency selection diff --git a/docs/src/pages/guide/form-inputs/emojis.md b/docs/src/pages/guide/form-inputs/emojis.md new file mode 100644 index 000000000..9e9b42509 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/emojis.md @@ -0,0 +1,62 @@ +--- +sidebarTitle: Emojis +sidebarPos: 14 +--- + +# Emojis + +`VInputEmojis` is an emoji picker dialog. It renders a grid of ~180 emoji buttons inside a `v-dialog` and emits the selected emoji as a string. The dialog visibility is controlled via `v-model`. There is no corresponding PHP hydrate — this component is used directly in Vue templates. + +## Vue Component + +**Registered as:** `VInputEmojis` +**File:** `vue/src/js/components/inputs/Emojis.vue` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Boolean` | `false` | Controls dialog visibility (`true` = open) | +| `disabled` | `Boolean` | `false` | Reserved; currently does not suppress the dialog | + +### Emits + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `Boolean` | Dialog open/close state change | +| `emoji-selected` | `String` | The emoji character the user clicked | + +### Usage + +```vue +<script setup> +import { ref } from 'vue' + +const pickerOpen = ref(false) +const message = ref('') + +function onEmojiSelected(emoji) { + message.value += emoji +} +</script> + +<template> + <v-btn @click="pickerOpen = true">Pick Emoji</v-btn> + + <VInputEmojis + v-model="pickerOpen" + @emoji-selected="onEmojiSelected" + /> +</template> +``` + +### Behaviour + +- The picker renders a scrollable `8-column` grid (max height 300 px). +- Selecting an emoji emits `emoji-selected` and immediately closes the dialog. +- The built-in emoji set covers faces, hand gestures, hearts, symbols, and more (~180 entries). + +## See Also + +- [Chat](/guide/form-inputs/input-chat) — Chat input that uses emoji picker integration +- [Forms overview](/guide/form-inputs/overview) — Schema-driven form architecture diff --git a/docs/src/pages/guide/form-inputs/input-assignment.md b/docs/src/pages/guide/form-inputs/input-assignment.md new file mode 100644 index 000000000..0bd04cb4c --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-assignment.md @@ -0,0 +1,96 @@ +--- +sidebarPos: 2 +sidebarTitle: Assignment +--- + +# Assignment + +The `assignment` input type renders `VInputAssignment`, a task-assignment widget that lets authorised users assign a record to another user, track due dates, add descriptions, and view assignment history. It is backed by a full assignment lifecycle (create → confirm → complete). + +## Hydrate + +**Class:** `AssignmentHydrate` +**Config type:** `assignment` +**Output type:** `input-assignment` → `VInputAssignment` + +The hydrate: +- Defaults `name` to `assignable_id`; `noSubmit: true` (the field never submits its value directly) +- Resolves `assigneeType` from the target module's model when not explicitly set +- Fetches `items` (the list of possible assignees) via `assigneeType::query()`, optionally scoped by `scopeRole` +- Auto-resolves `fetchEndpoint` → `{module}.{route}.assignments` and `saveEndpoint` → `{module}.{route}.createAssignment` +- Builds a Filepond schema for attachments (PDF by default, up to 3 files, 10 MB each) +- Requires both `_moduleName` and `_routeName` to be available in the config pipeline + +## Usage + +### Minimal (auto-resolved) + +```php +[ + 'type' => 'assignment', +] +``` + +### Restrict assignees by role + +```php +[ + 'type' => 'assignment', + 'scopeRole' => ['manager', 'admin'], +] +``` + +### Explicit assignee type and file limits + +```php +[ + 'type' => 'assignment', + 'assigneeType' => \App\Models\User::class, + 'assignableType' => \App\Models\Task::class, + 'maxFileSize' => '5MB', + 'max-attachments' => 5, + 'acceptedExtensions' => ['pdf', 'jpg', 'png'], +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `name` | `'assignable_id'` | Form field name | +| `noSubmit` | `true` | Prevents the field from being submitted directly | +| `col` | `{cols: 12}` | Always full width | +| `default` | `null` | No pre-selected assignee | +| `authorizedRoles` | `['superadmin', 'admin']` | Roles that can create/view assignments | +| `minDueDays` | `0` | Minimum days ahead a due date can be set | + +## Vue Component + +**Registered as:** `VInputAssignment` +**File:** `vue/src/js/components/inputs/Assignment.vue` + +### Additional Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `items` | `Array` | `[]` | List of possible assignees (pre-loaded by hydrate) | +| `fetchEndpoint` | `String` | `null` | URL to fetch existing assignments (`:id` replaced at runtime) | +| `saveEndpoint` | `String` | `null` | URL to create/update assignments (`:id` replaced at runtime) | +| `assignableType` | `String` | `null` | Fully-qualified model class of the record being assigned | +| `assigneeType` | `String` | `null` | Fully-qualified model class of the assignee | +| `authorizedRoles` | `Array` | `['superadmin','admin']` | Roles that can create and view assignments | +| `minDueDays` | `Number` | `0` | Minimum days ahead for the due date | +| `filepond` | `Object` | `null` | Filepond schema for attachment uploads | +| `variant` | `String` | `'outlined'` | Vuetify field variant | +| `density` | `String` | `'default'` | Vuetify field density | + +### Behaviour + +- On mount, fetches existing assignments from `fetchEndpoint` and displays the most recent one. +- The **Assign** button (shown to authorized roles) opens a modal to create a new assignment with assignee, due date, description, and optional attachments. +- The **History** button opens a scrollable list of all past assignments. +- `noSubmit: true` — the field value is never included in the standard form submission; all data is sent via direct Axios calls to the save endpoint. + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-autocomplete.md b/docs/src/pages/guide/form-inputs/input-autocomplete.md new file mode 100644 index 000000000..2f97802e2 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-autocomplete.md @@ -0,0 +1,81 @@ +--- +sidebarPos: 3 +sidebarTitle: Autocomplete +--- + +# Autocomplete + +The `autocomplete` input type renders a searchable select field backed by Vuetify's `v-autocomplete`. It supports both static item lists and remote data via `connector` or `endpoint`. + +## Hydrate + +**Class:** `AutocompleteHydrate` +**Config type:** `autocomplete` +**Output type:** `select` (with autocomplete behaviour) or `input-select-scroll` when scroll mode is active + +## Usage + +### Static items + +```php +[ + 'type' => 'autocomplete', + 'name' => 'category_id', + 'label' => 'Category', + 'items' => $categories, // pre-loaded array + 'itemValue' => 'id', + 'itemTitle' => 'name', +] +``` + +### Remote items via connector + +```php +[ + 'type' => 'autocomplete', + 'name' => 'category_id', + 'label' => 'Category', + 'connector' => 'Blog:Category|repository:list', +] +``` + +### Scroll mode (large datasets) + +When `ext: 'scroll'` is set alongside an `endpoint` or `connector`, the hydrate automatically switches the output type to `input-select-scroll` with `componentType: 'v-autocomplete'`. + +```php +[ + 'type' => 'autocomplete', + 'name' => 'tag_id', + 'label' => 'Tag', + 'ext' => 'scroll', + 'connector' => 'Blog:Tag|repository:list', +] +``` + +### Multiple selection + +```php +[ + 'type' => 'autocomplete', + 'name' => 'tag_ids', + 'label' => 'Tags', + 'multiple' => true, + 'connector' => 'Blog:Tag|repository:list', +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | The field used as the option value | +| `itemTitle` | `'name'` | The field used as the option label | +| `default` | `[]` | Default selection (reset to `null` for single-select) | +| `cascadeKey` | `'items'` | Key used when cascading items between inputs | +| `returnObject` | `false` | Return the full object instead of just the value | + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract +- [Relationships](/guide/generics/relationships) — Using `connector` to load remote data diff --git a/docs/src/pages/guide/form-inputs/input-browser.md b/docs/src/pages/guide/form-inputs/input-browser.md new file mode 100644 index 000000000..f0a8c5690 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-browser.md @@ -0,0 +1,128 @@ +--- +sidebarPos: 4 +sidebarTitle: Browser +--- + +# Browser + +The `browser` input type opens an inline record browser that lets users search and pick records from another module route. Used internally by the **Creator** feature and can appear in any form. + +**Files:** +- PHP: `src/Hydrates/Inputs/BrowserHydrate.php` +- Vue: `vue/src/js/components/inputs/Browser.vue` + +--- + +## Hydrate + +**Class:** `BrowserHydrate` +**Config type:** `browser` +**Output type:** `input-browser` + +When `_moduleName` and `_routeName` are both set, the hydrate resolves `endpoint` automatically from the module's index action URL. Otherwise, provide `endpoint` directly. + +### Default Requirements + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | Field used as the record identifier | +| `itemTitle` | `'name'` | Field displayed for each record | +| `default` | `null` | Default selected value | +| `returnObject` | `false` | Return the full object instead of just the value | +| `label` | `'Browser'` | Input label | +| `multiple` | `false` | Allow selecting multiple records | +| `max` | `null` | Maximum selectable records (when `multiple: true`) | +| `objectModelValues` | `['*']` | Model attributes to include in the returned object | +| `objectIdDefiner` | `null` | Custom attribute to use as the record identifier | + +### Config Usage + +#### Basic — auto-resolved endpoint + +```php +[ + 'type' => 'browser', + 'name' => 'author_id', + 'label' => 'Author', + '_moduleName' => 'Blog', + '_routeName' => 'Author', +] +``` + +#### Manual endpoint + +```php +[ + 'type' => 'browser', + 'name' => 'author_id', + 'label' => 'Author', + 'endpoint' => '/admin/blog/authors', +] +``` + +#### Multiple selection with a limit + +```php +[ + 'type' => 'browser', + 'name' => 'author_ids', + 'label' => 'Authors', + '_moduleName' => 'Blog', + '_routeName' => 'Author', + 'multiple' => true, + 'max' => 3, +] +``` + +--- + +## Vue Component + +**Component:** `VInputBrowser` (`v-input-browser`) +**File:** `vue/src/js/components/inputs/Browser.vue` + +Opens a paginated search dialog to browse and select records from a remote endpoint. Supports single and multiple selection, `returnObject` mode, and preserves initial values across re-renders. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `*` | — | Selected value or array of values | +| `endpoint` | `String` | — | API URL to fetch records from | +| `itemValue` | `String` | `'id'` | Key used as the value for each record | +| `itemTitle` | `String` | `'name'` | Key displayed for each record | +| `multiple` | `Boolean` | `false` | Allow selecting multiple records | +| `returnObject` | `Boolean` | `false` | Emit the full object instead of only the value | +| `objectIdDefiner` | `String` | — | Override which key identifies a record when `returnObject` is true | +| `objectModelValues` | `Array` | `['*']` | Keys to include from the record when `returnObject` is true | +| `convertObject` | `Boolean` | `false` | Convert returned object to a flat value after selection | +| `items` | `Array` | `[]` | Pre-populate the browser list (skips initial fetch) | +| `page` | `Number` | `1` | Starting page | +| `lastPage` | `Number` | `-1` | Total page count (set by server response) | +| `itemsPerPage` | `Number` | `20` | Records per page | +| `with` | `Array` | `[]` | Eager-load relations on each record | +| `scopes` | `Array` | `[]` | Query scopes to apply on the server | +| `orders` | `Array` | `[]` | Order definitions `{ key, direction }` | +| `appends` | `Array` | `[]` | Model appends to include in the response | +| `column` | `Array` | `[]` | Columns to select | +| `searchKeys` | `Array` | — | Keys the search field filters by | +| `variant` | `String` | `'outlined'` | Vuetify input variant | +| `density` | `String` | `'comfortable'` | Vuetify input density | +| `rules` | `String\|Array` | `[]` | Validation rules | +| `useFullUrl` | `Boolean` | `false` | Use the full absolute URL when building requests | +| `preserveInitialValues` | `Boolean` | `true` | Keep previously selected values when the list reloads | + +### Behavior + +- Opens a **dialog** with a paginated, searchable list of records fetched from `endpoint`. +- On open, if `modelValue` already contains IDs, those records are fetched by `ids` param and shown as pre-selected. +- **Pagination** — loads the next page via infinite scroll or page navigation; appends results to the existing list. +- **Return modes** — when `returnObject: false` (default), emits the `itemValue` of selected records; when `returnObject: true`, emits the full record object filtered by `objectModelValues`. +- **Multiple** — when `multiple: false`, selecting a record immediately closes the dialog and emits the value. + +--- + +## See Also + +- [Forms Overview](/guide/form-inputs/overview) — Hydrate pipeline and schema contract +- [Creator feature](/guide/module-features/creator) — How `BrowserHydrate` is used by the Creator pattern diff --git a/docs/src/pages/guide/form-inputs/input-chat.md b/docs/src/pages/guide/form-inputs/input-chat.md new file mode 100644 index 000000000..2104320e5 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-chat.md @@ -0,0 +1,75 @@ +--- +sidebarPos: 5 +sidebarTitle: Chat +--- + +# Chat + +The `chat` input type renders `VInputChat`, a full messaging thread embedded directly in a form. It supports paginated message history, file attachments via Filepond, message pinning, starring, and an expandable "Show All" dialog. The widget does **not** submit its value as part of the form (`noSubmit: true`). + +## Hydrate + +**Class:** `ChatHydrate` +**Config type:** `chat` +**Output type:** `input-chat` → `VInputChat` + +The hydrate: +- Sets `name` to `_chat_id` and forces `noSubmit: true` and `rules: ''` +- Auto-wires all chat endpoints (`index`, `store`, `show`, `update`, `destroy`, `attachments`, `pinnedMessage`) via named routes (`admin.chatable.*`) +- Embeds a Filepond attachment config (defaults: PDF/doc/docx/pages, max 3 files) +- Sets `default` to `-1` (no active chat until a record is loaded) +- Sets `creatable: 'hidden'` so the Create form hides this field + +## Usage + +### Minimal + +```php +[ + 'type' => 'chat', +] +``` + +### Custom height and attachment types + +```php +[ + 'type' => 'chat', + 'label' => 'Support Messages', + 'height' => '60vh', + 'bodyHeight' => '44vh', + 'acceptedExtensions' => ['pdf', 'jpg', 'png'], + 'max-attachments' => 5, +] +``` + +### Custom card styling + +```php +[ + 'type' => 'chat', + 'variant' => 'elevated', + 'color' => 'blue-lighten-5', + 'elevation' => 2, + 'inputVariant' => 'outlined', +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `default` | `-1` | No chat until record is loaded | +| `height` | `'40vh'` | Total widget height | +| `bodyHeight` | `'26vh'` | Scrollable message area height | +| `variant` | `'outlined'` | Card variant | +| `elevation` | `0` | Card elevation | +| `color` | `'grey-lighten-2'` | Card background colour | +| `inputVariant` | `'outlined'` | Text input variant | +| `noSubmit` | `true` | Not included in form submission | +| `creatable` | `'hidden'` | Hidden on the Create form | + +## See Also + +- [Filepond](/guide/form-inputs/input-filepond) — Used internally for message attachments +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-checkbox.md b/docs/src/pages/guide/form-inputs/input-checkbox.md new file mode 100644 index 000000000..897d7fcdf --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-checkbox.md @@ -0,0 +1,63 @@ +--- +sidebarPos: 6 +sidebarTitle: Checkbox +--- + +# Checkbox + +The `checkbox` input type renders a single boolean toggle using Vuetify's `v-checkbox`. It stores a truthy/falsy value and is appropriate for boolean flags on a model. + +## Hydrate + +**Class:** `CheckboxHydrate` +**Config type:** `checkbox` +**Output type:** `checkbox` (Vuetify `v-checkbox`) + +## Usage + +### Basic checkbox + +```php +[ + 'type' => 'checkbox', + 'name' => 'is_active', + 'label' => 'Active', +] +``` + +### Custom true/false values + +```php +[ + 'type' => 'checkbox', + 'name' => 'is_featured', + 'label' => 'Featured', + 'trueValue' => 'yes', + 'falseValue' => 'no', +] +``` + +### Custom color + +```php +[ + 'type' => 'checkbox', + 'name' => 'is_published', + 'label' => 'Published', + 'color' => 'primary', +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `color` | `'success'` | Vuetify color for the checked state | +| `trueValue` | `1` | Value stored when the checkbox is checked | +| `falseValue` | `0` | Value stored when the checkbox is unchecked | +| `hideDetails` | `true` | Hide validation detail row below the checkbox | +| `default` | `0` | Initial value (always set to `0` by the hydrate) | + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/components/input-checklist-group.md b/docs/src/pages/guide/form-inputs/input-checklist-group.md similarity index 60% rename from docs/src/pages/guide/components/input-checklist-group.md rename to docs/src/pages/guide/form-inputs/input-checklist-group.md index 145f1e328..dd203d3b5 100644 --- a/docs/src/pages/guide/components/input-checklist-group.md +++ b/docs/src/pages/guide/form-inputs/input-checklist-group.md @@ -1,5 +1,6 @@ --- -# sidebarPos: 3 +sidebarPos: 9 +sidebarTitle: Checklist Group --- # Checklist Group <Badge type="tip" text="^0.9.2" /> @@ -33,6 +34,21 @@ It needs a schema attribute like standard-schema pattern. Types must be checklis > [!IMPORTANT] > This component was introduced in [v0.9.2] -## See also +## Hydrate -- [Module Features Overview](/guide/module-features/) — Config types and output types (checklist) +**Class:** `ChecklistGroupHydrate` +**Config type:** `checklist-group` +**Output type:** `input-checklist-group` → `VInputChecklistGroup` + +The hydrate sets `type` to `input-checklist-group` and filters the `schema` array to remove any checklist entries that have no `items` — preventing empty groups from rendering. + +### Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `default` | `[]` | Default selected values | + +## See Also + +- [Module Features Overview](/guide/module-features/overview) — Config types and output types (checklist) +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-checklist.md b/docs/src/pages/guide/form-inputs/input-checklist.md new file mode 100644 index 000000000..61aa6087b --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-checklist.md @@ -0,0 +1,146 @@ +--- +sidebarPos: 8 +sidebarTitle: Checklist +--- + +# Checklist + +The `checklist` input type renders a multi-select checkbox list. It supports flat items, grouped/treeview layouts, and cascade filtering. The value stored is an array of selected `itemValue` values. + +**Files:** +- PHP: `src/Hydrates/Inputs/ChecklistHydrate.php` +- Vue: `vue/src/js/components/inputs/Checklist.vue` + +--- + +## Hydrate + +**Class:** `ChecklistHydrate` +**Config type:** `checklist` +**Output type:** `input-checklist` + +The hydrate sets `type` to `input-checklist` and applies the default requirements. Items can be provided statically or loaded via `connector`. + +### Default Requirements + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | Field used as the stored value | +| `itemTitle` | `'name'` | Field displayed for each item | +| `default` | `[]` | No items selected by default | +| `cascadeKey` | `'items'` | Key used when cascading filtered items | + +### Config Usage + +#### Static items + +```php +[ + 'type' => 'checklist', + 'name' => 'permissions', + 'label' => 'Permissions', + 'items' => [ + ['id' => 1, 'name' => 'Read'], + ['id' => 2, 'name' => 'Write'], + ['id' => 3, 'name' => 'Delete'], + ], +] +``` + +#### Remote items via connector + +```php +[ + 'type' => 'checklist', + 'name' => 'category_ids', + 'label' => 'Categories', + 'connector' => 'Blog:Category|repository:list', +] +``` + +#### With selected label + +```php +[ + 'type' => 'checklist', + 'name' => 'country_ids', + 'label' => 'Select Countries', + 'selectedLabel' => 'Selected Countries', + 'connector' => 'Location:Country|repository:list', +] +``` + +#### Treeview (grouped items) + +Set `isTreeview: true` and ensure items have a nested `items` key for child groups. + +```php +[ + 'type' => 'checklist', + 'name' => 'region_ids', + 'label' => 'Regions', + 'isTreeview' => true, + 'connector' => 'Location:Region|repository:list:withs=children', +] +``` + +--- + +## Vue Component + +**Component:** `VInputChecklist` (`v-input-checklist`) +**File:** `vue/src/js/components/inputs/Checklist.vue` + +Renders checkboxes in a responsive grid. Supports flat list, grouped treeview, card style, and `max` selection limit. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Array` | `[]` | Selected values (array of `itemValue`) | +| `items` | `Array` | `[]` | List of item objects | +| `itemValue` | `String` | `'id'` | Key used as the checkbox value | +| `itemTitle` | `String` | `'name'` | Key used as the checkbox label | +| `label` | `String` | `null` | Input label shown above checkboxes | +| `subtitle` | `String` | `null` | Secondary label shown below the main label | +| `labelColor` | `String` | `'grey-darken-1'` | Label text color | +| `subtitleColor` | `String` | `'grey-darken-1'` | Subtitle text color | +| `disabled` | `Boolean` | `false` | Disable all checkboxes | +| `readonly` | `Boolean` | `false` | Make all checkboxes read-only | +| `isTreeview` | `Boolean` | `false` | Render items as a collapsible treeview | +| `isCard` | `Boolean` | `false` | Render each item as a card instead of a plain checkbox | +| `max` | `Number\|String` | `null` | Maximum number of selectable items | +| `mandatory` | `String` | `null` | Dot-path key on each item; if truthy, that item cannot be deselected | +| `flexColumn` | `Boolean` | `true` | Lay out label and checkboxes side-by-side on `md+` screens | +| `checkboxPosition` | `String` | `'right'` | `'left'` or `'right'` — side the checkbox icon appears | +| `checkboxHighlighted` | `Boolean` | `false` | Highlight selected checkbox rows with a background | +| `checkboxHighlightedColor` | `String` | `'grey-lighten-5'` | Background color when highlighted | +| `checkboxCol` | `Object` | `{ cols:3, sm:6, md:4, lg:3 }` | Vuetify column breakpoints for each checkbox | +| `orderBy` | `String` | `null` | Item key to sort by | +| `orderByDirection` | `String` | `'asc'` | `'asc'` or `'desc'` | +| `chunkField` | `String` | `null` | Group items by this field key | +| `chunkCharacter` | `String` | `'_'` | Delimiter used to auto-detect groups from `itemTitle` | +| `chunkTitleKey` | `String` | `'name'` | Key used when deriving group labels | +| `truncateItemLabel` | `Boolean` | `false` | Truncate long labels with ellipsis | +| `noGroupAllSelectable` | `Boolean` | `false` | Hide the "select all" checkbox on treeview group headers | +| `hasGroupBottomDivider` | `Boolean` | `true` | Show a divider below each group header in treeview | +| `openAllGroups` | `Boolean` | `false` | Expand all treeview groups on mount | +| `closeAllGroups` | `Boolean` | `false` | Collapse all treeview groups on mount | +| `cardStats` | `Array` | `[]` | Stat definitions `{ key, label }` shown inside each card item | +| `groupExpandGap` | `String` | `'4'` | Gap between treeview groups (Vuetify spacing unit) | +| `groupExpandTitleProps` | `Object` | `{}` | Extra props passed to group header title when `chunkField` is set | + +### Behavior + +- **Max selection** — when `max` is set (or inferred from `rules: 'max:N'`), checkboxes disable once the limit is reached. Mandatory items are always pre-selected and cannot be deselected. +- **Treeview** — when `isTreeview: true`, items must have a nested `items` array. Group headers get a "select all" checkbox; `openAllGroups` / `closeAllGroups` control initial state. +- **Card mode** — when `isCard: true`, each item renders as a `VInputCheckboxCard`. Use `cardStats` to show metric values inside each card. +- **Grouping without treeview** — when `chunkField` is set, flat items are grouped by that field value. Alternatively, `chunkCharacter` splits `itemTitle` on a delimiter to infer group names. + +--- + +## See Also + +- [Checklist Group](/guide/form-inputs/input-checklist-group) — Multiple checklist schemas in a single input +- [Forms Overview](/guide/form-inputs/overview) — Hydrate pipeline and schema contract +- [Relationships](/guide/generics/relationships) — Using `connector` to load remote data diff --git a/docs/src/pages/guide/form-inputs/input-combobox.md b/docs/src/pages/guide/form-inputs/input-combobox.md new file mode 100644 index 000000000..d4cf02ade --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-combobox.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 10 +sidebarTitle: Combobox +--- + +# Combobox + +The `combobox` input type renders Vuetify's `v-combobox`, which allows users to either select an existing option or type in a free-form value. Like `autocomplete`, it supports static items, remote data via `connector`, and scroll mode for large datasets. + +## Hydrate + +**Class:** `ComboboxHydrate` +**Config type:** `combobox` +**Output type:** `combobox` or `input-select-scroll` (scroll mode) + +## Usage + +### Static items + +```php +[ + 'type' => 'combobox', + 'name' => 'tags', + 'label' => 'Tags', + 'items' => ['php', 'laravel', 'vue'], + 'multiple' => true, +] +``` + +### Remote items via connector + +```php +[ + 'type' => 'combobox', + 'name' => 'category', + 'label' => 'Category', + 'connector' => 'Blog:Category|repository:list', +] +``` + +### Scroll mode (large datasets) + +When `ext: 'scroll'` is set alongside an `endpoint` or `connector`, the hydrate switches to `input-select-scroll` with `componentType: 'v-combobox'`. + +```php +[ + 'type' => 'combobox', + 'name' => 'city', + 'label' => 'City', + 'ext' => 'scroll', + 'connector' => 'Location:City|repository:list', +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | The field used as the option value | +| `itemTitle` | `'name'` | The field displayed for each option | +| `default` | `[]` | Default selection (reset to `null` for single-select) | +| `cascadeKey` | `'items'` | Key used when cascading items between inputs | +| `returnObject` | `false` | Return the full object instead of just the value | + +> **Combobox vs Autocomplete:** Both use the same hydrate logic. The difference is the underlying Vuetify component — `v-combobox` allows free-form input while `v-autocomplete` restricts selection to existing options. + +## See Also + +- [Autocomplete](/guide/form-inputs/input-autocomplete) — Same schema, restricts to existing options only +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract +- [Relationships](/guide/generics/relationships) — Using `connector` to load remote data diff --git a/docs/src/pages/guide/components/input-comparison-table.md b/docs/src/pages/guide/form-inputs/input-comparison-table.md similarity index 64% rename from docs/src/pages/guide/components/input-comparison-table.md rename to docs/src/pages/guide/form-inputs/input-comparison-table.md index 0897a9c4e..314fac8ed 100644 --- a/docs/src/pages/guide/components/input-comparison-table.md +++ b/docs/src/pages/guide/form-inputs/input-comparison-table.md @@ -1,5 +1,6 @@ --- -# sidebarPos: 3 +sidebarPos: 11 +sidebarTitle: Comparison Table --- # Comparison Table <Badge type="tip" text="^0.9.2" /> @@ -28,5 +29,19 @@ You can consider it just like select input in regards to **items** attribute, yo > [!IMPORTANT] > This component was introduced in [v0.9.2] +## Hydrate -### +**Class:** `ComparisonTableHydrate` +**Config type:** `comparison-table` +**Output type:** `input-comparison-table` → `VInputComparisonTable` + +The hydrate sets `type` to `input-comparison-table` and automatically adds each key of the `comparators` map to the model's `with()` eager loads via the `withs()` method. This ensures related data is available when the table renders. + +### Schema Defaults + +No default keys are set. All schema is driven by the `comparators` and `connector` / `items` you provide. + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract +- [Relationships](/guide/generics/relationships) — Using `connector` to load remote data diff --git a/docs/src/pages/guide/form-inputs/input-date.md b/docs/src/pages/guide/form-inputs/input-date.md new file mode 100644 index 000000000..9fd00ea3a --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-date.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 13 +sidebarTitle: Date +--- + +# Date + +The `date` input type renders `VInputDate`, a date-picker powered by Vuetify's `v-date-input`. It handles timezone offset correction automatically so the date stored on the server matches the user's selected calendar date regardless of browser timezone. + +## Hydrate + +**Class:** `DateHydrate` +**Config type:** `date` +**Output type:** `input-date` → `VInputDate` + +The hydrate sets `type` to `input-date`. No default schema keys are set by the hydrate — all styling is passed through `$attrs`. + +## Usage + +### Basic date picker + +```php +[ + 'type' => 'date', + 'name' => 'published_at', + 'label' => 'Publish Date', +] +``` + +### With validation rules + +```php +[ + 'type' => 'date', + 'name' => 'due_date', + 'label' => 'Due Date', + 'rules' => 'required', +] +``` + +### Timezone-aware (no offset correction) + +By default the component subtracts the browser's UTC offset before emitting the value, keeping the stored date consistent across timezones. Set `useTimezone: true` to disable this behaviour and emit the raw selected date. + +```php +[ + 'type' => 'date', + 'name' => 'event_date', + 'label' => 'Event Date', + 'useTimezone' => true, +] +``` + +## Schema Defaults + +No hydrate-level defaults. The component accepts all standard Vuetify `v-date-input` attributes via `$attrs`. + +| Prop | Default | Description | +|------|---------|-------------| +| `variant` | `'outlined'` | Vuetify field variant | +| `density` | `'default'` | Vuetify density | +| `useTimezone` | `false` | When `false`, corrects UTC offset before emitting | + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-file.md b/docs/src/pages/guide/form-inputs/input-file.md new file mode 100644 index 000000000..6a7b853b9 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-file.md @@ -0,0 +1,65 @@ +--- +sidebarPos: 15 +sidebarTitle: File +--- + +# File + +The `file` input type renders `VInputFile`, a drag-and-drop file list backed by the Modularous media library. It supports multiple files, drag-to-reorder (via `vuedraggable`), and displays each file as a row with a delete button. Unlike [Filepond](/guide/form-inputs/input-filepond), this component uses the internal media library rather than the Filepond server protocol. + +## Hydrate + +**Class:** `FileHydrate` +**Config type:** `file` +**Output type:** `input-file` → `VInputFile` + +The hydrate sets `type` to `input-file` and defaults `label` to the translated string `"Files"`. + +## Usage + +### Basic + +```php +[ + 'type' => 'file', + 'name' => 'documents', + 'label' => 'Documents', +] +``` + +### Translated files + +```php +[ + 'type' => 'file', + 'name' => 'files', + 'translated' => true, +] +``` + +### Limit the number of files + +Pass `max` to cap how many files can be attached. + +```php +[ + 'type' => 'file', + 'name' => 'attachments', + 'max' => 3, +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `name` | `'files'` | Form field name | +| `translated` | `false` | Whether files are locale-specific | +| `default` | `[]` | Empty file list | +| `label` | `'Files'` | Auto-translated label | + +## See Also + +- [Filepond](/guide/form-inputs/input-filepond) — Alternative upload using the Filepond protocol +- [File Storage with Filepond](/guide/generics/file-storage-with-filepond) — Setup guide +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-filepond-avatar.md b/docs/src/pages/guide/form-inputs/input-filepond-avatar.md new file mode 100644 index 000000000..dc573bd1a --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-filepond-avatar.md @@ -0,0 +1,57 @@ +--- +sidebarPos: 16 +sidebarTitle: Filepond Avatar +--- + +# Filepond Avatar <Badge type="tip" text="^0.9.2" /> + +The `filepond-avatar` input type renders `VInputFilepondAvatar`, a specialised variant of the Filepond upload component for avatar / profile photos. It limits uploads to a maximum of 2 files, auto-wires all Filepond server endpoints (process, revert, preview), and hides the Filepond credits banner. + +## Hydrate + +**Class:** `FilepondAvatarHydrate` +**Config type:** `filepond-avatar` +**Output type:** `input-filepond-avatar` → `VInputFilepondAvatar` + +The hydrate: +- Sets `max` to `2` (hardcoded — avatars are capped at two files) +- Auto-resolves the three Filepond server endpoints from named routes (`filepond.process`, `filepond.revert`, `filepond.preview`) +- Sets `credits` to `false` to suppress the "powered by Filepond" banner +- Passes through `acceptedExtensions` when provided + +## Usage + +```php +[ + 'type' => 'filepond-avatar', + 'name' => 'avatar', + 'label' => 'Profile Photo', +] +``` + +### Restrict accepted file types + +```php +[ + 'type' => 'filepond-avatar', + 'name' => 'avatar', + 'label' => 'Profile Photo', + 'acceptedExtensions' => ['jpg', 'jpeg', 'png', 'webp'], +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `max` | `2` | Maximum number of files (hardcoded by hydrate) | +| `credits` | `false` | Hide the Filepond credits banner | +| `process` | route(`filepond.process`) | Server endpoint for uploads | +| `revert` | route(`filepond.revert`) | Server endpoint to cancel/revert an upload | +| `load` | route(`filepond.preview`) | Server endpoint to load existing files | + +## See Also + +- [Filepond](/guide/form-inputs/input-filepond) — General-purpose file upload with Filepond +- [File Storage with Filepond](/guide/generics/file-storage-with-filepond) — Setup and configuration +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/components/input-filepond.md b/docs/src/pages/guide/form-inputs/input-filepond.md similarity index 86% rename from docs/src/pages/guide/components/input-filepond.md rename to docs/src/pages/guide/form-inputs/input-filepond.md index 85b31bd60..b8e642b80 100644 --- a/docs/src/pages/guide/components/input-filepond.md +++ b/docs/src/pages/guide/form-inputs/input-filepond.md @@ -1,17 +1,17 @@ --- outline: deep -sidebarPos: 5 +sidebarPos: 20 --- # Filepond - File Input Component -`FilePond` is a JavaScript library that provides smooth drag-and-drop file uploading. By implementing the FilePond Vue component for image and file uploads, `Modularity` offers users easily implementable, configurable, and versatile file processing functionality. +`FilePond` is a JavaScript library that provides smooth drag-and-drop file uploading. By implementing the FilePond Vue component for image and file uploads, `Modularous` offers users easily implementable, configurable, and versatile file processing functionality. ::: tip One to Many Polymorphic Bounding -There is another way to process files/ medias with modularity, that is using file/media libraries. Unlike using file/media library, `FilePond with Modularity` offers `one to many` bounding between models and files. +There is another way to process files/ medias with modularous, that is using file/media libraries. Unlike using file/media library, `FilePond with Modularous` offers `one to many` bounding between models and files. ::: ## Feature Implementation Road Map @@ -27,7 +27,7 @@ In order to effectively use the FilePond component and its functionalities on th ::: info -Modularity serves most of the functionalities over traits. See [File Storage with Filepond](/guide/generics/file-storage-with-filepond) for the full implementation guide. +Modularous serves most of the functionalities over traits. See [File Storage with Filepond](/guide/generics/file-storage-with-filepond) for the full implementation guide. ::: @@ -66,13 +66,13 @@ Route's configuration files allow you to configure input component and its metad ``` - ### Advanced options and avaliable props + ### Advanced options and available props <br/> #### `accepted-file-types` - Controlls the allowable file types to be uploaded to your model. For an example, Can be defined as `file/pdf, image/*` to allow all image types and pdf types only. Different types should be seperated with comma `,` . + Controls the allowable file types to be uploaded to your model. For an example, Can be defined as `file/pdf, image/*` to allow all image types and pdf types only. Different types should be separated with comma `,` . * `Input Type:` `String` * `Default:` `file/*, image/*` (all types of files and image types) @@ -91,7 +91,7 @@ Route's configuration files allow you to configure input component and its metad #### `max-files` - Controlls the maximum number of files can be upload. + Controls the maximum number of files that can be uploaded. * `Input Type:` `String|Number|null` * `Default:` `null` (unlimited) @@ -157,7 +157,7 @@ Route's configuration files allow you to configure input component and its metad #### `allow-image-preview` - Configures the image preview will be shown or not. + Configures whether the image preview will be shown. * `Input Type:` `Boolean` * `Variance:` `true|false|null` diff --git a/docs/src/pages/guide/components/input-form-groups.md b/docs/src/pages/guide/form-inputs/input-form-groups.md similarity index 96% rename from docs/src/pages/guide/components/input-form-groups.md rename to docs/src/pages/guide/form-inputs/input-form-groups.md index a98dca932..56581ceba 100644 --- a/docs/src/pages/guide/components/input-form-groups.md +++ b/docs/src/pages/guide/form-inputs/input-form-groups.md @@ -1,5 +1,6 @@ --- -# sidebarPos: 3 +sidebarPos: 17 +sidebarTitle: Form Groups --- # Tab Group <Badge type="tip" text="^0.9.2" /> diff --git a/docs/src/pages/guide/form-inputs/input-form-tabs.md b/docs/src/pages/guide/form-inputs/input-form-tabs.md new file mode 100644 index 000000000..dd28aa245 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-form-tabs.md @@ -0,0 +1,89 @@ +--- +sidebarPos: 18 +sidebarTitle: Form Tabs +--- + +# Form Tabs <Badge type="tip" text="^0.9.2" /> + +The `tab-group` input type renders `VInputFormTabs`, which wraps a nested `schema` array into a tabbed form layout. Each tab contains its own set of inputs. The hydrate automatically discovers which nested inputs require eager or lazy relationship loading so the parent form can pre-fetch the right data. + +> [!NOTE] +> This is different from the display-only `ue-tab-groups` component. `Form Tabs` is an input that participates in form submission; `ue-tab-groups` is a layout wrapper for displaying content in tabs. + +## Hydrate + +**Class:** `FormTabsHydrate` +**Config type:** `tab-group` +**Output type:** `input-form-tabs` → `VInputFormTabs` + +The hydrate scans each input in the nested `schema` and builds two lists: + +- **`eagers`** — relationships that should be loaded immediately (inputs of type `checklist`, `select`, `combobox`, `autocomplete`, `comparison-table` whose connector/repository resolves an endpoint) +- **`lazy`** — relationships deferred until the tab is first opened + +Set `noEager: true` on an individual schema input to force it into the `lazy` list regardless of type. + +## Usage + +```php +[ + 'type' => 'tab-group', + 'name' => 'details', + 'label' => 'Details', + 'schema' => [ + [ + 'tab' => 'General', + 'inputs' => [ + [ + 'type' => 'text', + 'name' => 'title', + 'label' => 'Title', + ], + [ + 'type' => 'select', + 'name' => 'category_id', + 'label' => 'Category', + 'connector' => 'Blog:Category|repository:list', + ], + ], + ], + [ + 'tab' => 'Settings', + 'inputs' => [ + [ + 'type' => 'checkbox', + 'name' => 'is_published', + 'label' => 'Published', + ], + ], + ], + ], +] +``` + +### Deferring a relationship to lazy load + +```php +[ + 'type' => 'select', + 'name' => 'tag_ids', + 'label' => 'Tags', + 'connector'=> 'Blog:Tag|repository:list', + 'noEager' => true, // load only when this tab is first opened +] +``` + +## Schema Defaults + +No top-level defaults are set by the hydrate. All schema is driven by the `schema` array you provide. + +| Key | Description | +|-----|-------------| +| `schema` | **Required.** Array of tab objects, each with a `tab` label and `inputs` array | +| `eagers` | Auto-populated: relationships to load immediately | +| `lazy` | Auto-populated: relationships to defer until tab open | + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract +- [Relationships](/guide/generics/relationships) — Using `connector` to load remote data diff --git a/docs/src/pages/guide/form-inputs/input-image.md b/docs/src/pages/guide/form-inputs/input-image.md new file mode 100644 index 000000000..b27872508 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-image.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 19 +sidebarTitle: Image +--- + +# Image + +The `image` input type renders `VInputImage`, an image picker backed by the Modularous media library. It shows thumbnails for each selected image, supports crop previews, download, delete, and can display multiple images in a grid layout. + +## Hydrate + +**Class:** `ImageHydrate` +**Config type:** `image` +**Output type:** `input-image` → `VInputImage` + +The hydrate sets `type` to `input-image` and defaults `label` to the translated string `"Images"`. + +## Usage + +### Single image + +```php +[ + 'type' => 'image', + 'name' => 'cover', + 'label' => 'Cover Image', + 'max' => 1, +] +``` + +### Gallery (multiple images) + +```php +[ + 'type' => 'image', + 'name' => 'gallery', + 'label' => 'Gallery', + 'max' => 10, +] +``` + +### Translated images + +```php +[ + 'type' => 'image', + 'name' => 'images', + 'translated' => true, +] +``` + +### Custom grid columns + +The `imageCol` prop controls the column layout of the image grid. Defaults to `{cols: 12, md: 6, lg: 4}`. + +```php +[ + 'type' => 'image', + 'name' => 'photos', + 'imageCol' => ['cols' => 12, 'md' => 4, 'lg' => 3], +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `name` | `'images'` | Form field name | +| `translated` | `false` | Whether images are locale-specific | +| `default` | `[]` | Empty image list | +| `label` | `'Images'` | Auto-translated label | + +## See Also + +- [File](/guide/form-inputs/input-file) — Non-image media library attachments +- [Filepond](/guide/form-inputs/input-filepond) — Alternative upload using the Filepond protocol +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-json-repeater.md b/docs/src/pages/guide/form-inputs/input-json-repeater.md new file mode 100644 index 000000000..d49c91e6a --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-json-repeater.md @@ -0,0 +1,55 @@ +--- +sidebarPos: 22 +sidebarTitle: Json Repeater +--- + +# Json Repeater + +The `json-repeater` input type is a thin alias over [Repeater](/guide/form-inputs/input-repeater). It renders `VInputRepeater` with `root` set to `'json-repeater'`, which tells the repeater to serialise each row as a JSON object rather than treating it as a relational record. + +## Hydrate + +**Class:** `JsonRepeaterHydrate` (extends `RepeaterHydrate`) +**Config type:** `json-repeater` +**Output type:** `input-repeater` → `VInputRepeater` + +`JsonRepeaterHydrate` is a single-line subclass of `RepeaterHydrate` with no overrides. The only effective difference is that the resolved `root` value becomes `'json-repeater'`, signalling to the frontend that rows should be JSON-serialised. + +## Usage + +```php +[ + 'type' => 'json-repeater', + 'name' => 'addresses', + 'label' => 'Addresses', + 'singularLabel'=> 'Address', + 'schema' => [ + [ + 'type' => 'text', + 'name' => 'street', + 'label' => 'Street', + ], + [ + 'type' => 'text', + 'name' => 'city', + 'label' => 'City', + ], + ], +] +``` + +## Schema Defaults + +Inherits all defaults from `RepeaterHydrate`: + +| Key | Default | Description | +|-----|---------|-------------| +| `root` | `'json-repeater'` | Tells the frontend to serialise rows as JSON | +| `col.cols` | `12` | Always full-width | +| `singularLabel` | (field label) | Label used on the "Add" button | + +## See Also + +- [Repeater](/guide/form-inputs/input-repeater) — Base repeater for relational rows +- [Json](/guide/form-inputs/input-json) — Single JSON group (non-repeating) +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-json.md b/docs/src/pages/guide/form-inputs/input-json.md new file mode 100644 index 000000000..3822d0b5d --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-json.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 21 +sidebarTitle: Json +--- + +# Json + +The `json` input type groups a nested set of inputs under a single field that is serialised as a JSON object. Despite the config type being `json`, the hydrate outputs the schema type `group` — the frontend treats it as a field group rather than a raw JSON editor. + +> [!IMPORTANT] +> The output schema type is **`group`**, not `json`. If you are looking for a raw JSON / code-editor component, this is not it. + +## Hydrate + +**Class:** `JsonHydrate` +**Config type:** `json` +**Output type:** `group` + +The hydrate sets `col.cols` to `12` so the group always spans the full row width. + +## Usage + +```php +[ + 'type' => 'json', + 'name' => 'meta', + 'label' => 'Meta', + 'schema' => [ + [ + 'type' => 'text', + 'name' => 'og_title', + 'label' => 'OG Title', + ], + [ + 'type' => 'text', + 'name' => 'og_description', + 'label' => 'OG Description', + ], + ], +] +``` + +The submitted value for `meta` will be a JSON-encoded object of the nested field values. + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `col.cols` | `12` | Always full-width | + +## See Also + +- [Json Repeater](/guide/form-inputs/input-json-repeater) — Repeatable JSON rows +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-payment-service.md b/docs/src/pages/guide/form-inputs/input-payment-service.md new file mode 100644 index 000000000..8ef58f9fc --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-payment-service.md @@ -0,0 +1,52 @@ +--- +sidebarPos: 24 +sidebarTitle: Payment Service +--- + +# Payment Service + +The `payment-service` input type renders `VInputPaymentService`, a full payment selector that displays available payment methods (credit card, transfer, external gateways), handles currency selection, VAT calculation, discount percentages, and transaction fee display. It integrates with the `SystemPayment` module. + +> [!IMPORTANT] +> This component requires the `SystemPayment` and `SystemPricing` modules to be installed and the `modularity.default_payment_service` config key to be set. + +## Hydrate + +**Class:** `PaymentServiceHydrate` +**Config type:** `payment-service` +**Output type:** `input-payment-service` → `VInputPaymentService` + +The hydrate: +- Loads `supportedCurrencies` from `PaymentCurrency` (filtered by user country if `useCountryBasedVatRates` is enabled) +- Loads `items` (external + transfer payment services) with their currencies +- Builds `currencyCardTypes` mapping ISO 4217 codes to card type logos +- Builds a `transferFormSchema` (hidden fields + Filepond receipt upload + TOS checkbox) +- Auto-resolves `paymentUrl`, `checkoutUrl`, and `completeUrl` from named routes +- Sets `includeTransactionFee` and `useCountryBasedVatRates` from Modularous config +- Sets `currencyConversionEndpoint` to `route('currency.convert')` + +## Usage + +```php +[ + 'type' => 'payment-service', + 'name' => 'payment', + 'label' => 'Payment Method', +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | Field used as the payment service value | +| `itemTitle` | `'name'` | Field displayed for each payment service | +| `default` | `[]` | No pre-selected service | +| `default_payment_service` | config value | From `modularity.default_payment_service` | +| `useCountryBasedVatRates` | config value | From `Modularity::shouldUseCountryBasedVatRates()` | +| `includeTransactionFee` | config value | From `Modularity::shouldIncludeTransactionFee()` | + +## See Also + +- [Price](/guide/form-inputs/input-price) — Numeric price input with currency selection +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-price.md b/docs/src/pages/guide/form-inputs/input-price.md new file mode 100644 index 000000000..b9693bea0 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-price.md @@ -0,0 +1,75 @@ +--- +sidebarPos: 26 +sidebarTitle: Price +--- + +# Price + +The `price` input type renders `VInputPrice`, a currency-aware numeric input for entering prices. It supports multiple currency rows, an optional VAT rate selector, an optional discount percentage field, and displays a formatted total when VAT or discount is active. + +> [!IMPORTANT] +> This component requires the `SystemPricing` module. Currencies are loaded via the `CurrencyProviderInterface` contract. + +## Hydrate + +**Class:** `PriceHydrate` +**Config type:** `price` +**Output type:** `input-price` → `VInputPrice` + +The hydrate: +- Defaults `name` to `prices` and `label` to `"Prices"` +- Sets `priceInputName` from `Price::$priceSavingKey` (typically `price_value`) +- Builds the `default` array from `Price::defaultAttributes()` with the first available currency +- Loads `items` (currencies) via `CurrencyProviderInterface::getCurrenciesForSelect()` +- When `hasVatRate: true`, loads `vatRates` from `VatRateRepository` +- Sets `clearable: false` + +## Usage + +### Basic price field + +```php +[ + 'type' => 'price', + 'name' => 'prices', + 'label' => 'Price', +] +``` + +### With VAT rate selector + +```php +[ + 'type' => 'price', + 'name' => 'prices', + 'hasVatRate' => true, +] +``` + +### With discount + +```php +[ + 'type' => 'price', + 'name' => 'prices', + 'hasDiscount' => true, + 'hasVatRate' => true, +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `name` | `'prices'` | Form field name | +| `col` | `{cols:6, sm:5, md:4}` | Default column width | +| `priceInputName` | `Price::$priceSavingKey` | Key within each price row | +| `clearable` | `false` | Cannot be cleared | +| `showVatRate` | `true` | Show the VAT rate selector (when `vatRates` are present) | +| `hasDiscount` | `false` | Show a discount percentage field | +| `numberMultiplier` | `100` | Internal multiplier for integer storage | + +## See Also + +- [Payment Service](/guide/form-inputs/input-payment-service) — Full payment method selector +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-process.md b/docs/src/pages/guide/form-inputs/input-process.md new file mode 100644 index 000000000..1c92caefa --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-process.md @@ -0,0 +1,83 @@ +--- +sidebarPos: 27 +sidebarTitle: Process +--- + +# Process + +The `process` input type renders `VInputProcess`, a stateful process tracker card. It displays the current status of a processable record, allows authorised roles to advance the process through its lifecycle (preparing → waiting\_for\_confirmation → confirmed → etc.), and optionally surfaces an inline form for editing the processable entity's data. + +> [!IMPORTANT] +> The model associated with the route must use the `Processable` trait. The hydrate throws if this trait is absent. + +## Hydrate + +**Class:** `ProcessHydrate` +**Config type:** `process` +**Output type:** `input-process` → `VInputProcess` + +The hydrate: +- Requires `_moduleName` and `_routeName` to be set in the config pipeline +- Validates the route model has `Unusualify\Modularity\Entities\Traits\Processable` +- Auto-resolves `fetchEndpoint` → `route('admin.process.show', [:id, eager?])` +- Auto-resolves `updateEndpoint` → `route('admin.process.update', [:id])` +- Overrides `name` to `process_id` + +## Usage + +### Minimal + +```php +[ + 'type' => 'process', +] +``` + +### With eager-loaded relationships + +```php +[ + 'type' => 'process', + 'eager' => ['documents', 'approvals'], +] +``` + +### With role-gated actions and custom status config + +```php +[ + 'type' => 'process', + 'processEditableRoles' => ['superadmin', 'manager'], + 'actionRoles' => [ + 'confirmed' => ['superadmin'], + 'rejected' => ['superadmin', 'manager'], + ], + 'statusConfiguration' => [ + 'preparing' => [ + 'title' => 'Preparing', + 'icon' => 'mdi-progress-clock', + 'color' => 'secondary', + 'next_action_label' => 'Submit for Review', + 'next_action_color' => 'primary', + 'dialog_title' => 'Submit for Review?', + 'dialog_confirm_text'=> 'Submit', + 'dialog_cancel_text' => 'Cancel', + 'response_message' => 'Submitted successfully.', + ], + ], +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `color` | `'grey'` | Card colour when no status colour is set | +| `cardVariant` | `'outlined'` | Vuetify card variant | +| `processableTitle` | `'name'` | Field used as the process card title | +| `eager` | `[]` | Relationships to eager-load with the process | +| `name` | `'process_id'` | Form field name (set by hydrate, not config) | + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-radio-group.md b/docs/src/pages/guide/form-inputs/input-radio-group.md new file mode 100644 index 000000000..1102fc191 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-radio-group.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 28 +sidebarTitle: Radio Group +--- + +# Radio Group <Badge type="tip" text="^0.9.2" /> + +The `radio-group` input type renders `VInputRadioGroup`, a set of radio buttons where exactly one option can be selected. Items can be provided inline or loaded via a `connector`. + +## Hydrate + +**Class:** `RadioGroupHydrate` +**Config type:** `radio-group` +**Output type:** `input-radio-group` → `VInputRadioGroup` + +The hydrate sets the `default` value to the `itemValue` of the first item in the `items` array. If `items` is empty or not provided, `default` is left unset. + +## Usage + +```php +[ + 'type' => 'radio-group', + 'name' => 'plan', + 'label' => 'Plan', + 'itemValue' => 'id', + 'itemTitle' => 'name', + 'items' => [ + ['id' => 1, 'name' => 'Basic'], + ['id' => 2, 'name' => 'Pro'], + ['id' => 3, 'name' => 'Enterprise'], + ], +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `default` | first item's `itemValue` | Pre-selected option; auto-set from `items[0]` | +| `itemValue` | `'id'` | The field used as the option value | +| `itemTitle` | `'name'` | The field displayed for each option | + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract + +> [!IMPORTANT] +> This component was introduced in [v0.9.2] diff --git a/docs/src/pages/guide/form-inputs/input-relationships.md b/docs/src/pages/guide/form-inputs/input-relationships.md new file mode 100644 index 000000000..65c5db52f --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-relationships.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 29 +sidebarTitle: Relationships +--- + +# Relationships + +The `relationships` input type is intended to render a card-based relationship picker (`VInputRelationships`). It is currently an **unfinished placeholder** — the hydrate's `hydrate()` method contains a `dd()` call, which means using this config type will halt execution in a running application. + +> [!WARNING] +> `RelationshipsHydrate::hydrate()` contains `dd()`. **Do not use `type: 'relationships'` in production.** This input type is not ready for use. + +## Hydrate + +**Class:** `RelationshipsHydrate` +**Config type:** `relationships` +**Output type:** `input-relationships` → `VInputRelationships` *(not yet functional)* + +## Schema Defaults + +These requirements are declared but never reach the frontend because the hydrate halts before completing: + +| Key | Default | Description | +|-----|---------|-------------| +| `color` | `'grey'` | Color theme for relationship cards | +| `cardVariant` | `'outlined'` | Vuetify card variant | +| `processableTitle` | `'name'` | Field used as the display title for each related record | +| `eager` | `[]` | Relationships to eager-load | + +## See Also + +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract +- [Relationships (generics)](/guide/generics/relationships) — Using `connector` for remote data (unrelated to this input type) diff --git a/docs/src/pages/guide/form-inputs/input-repeater.md b/docs/src/pages/guide/form-inputs/input-repeater.md new file mode 100644 index 000000000..0d798b23d --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-repeater.md @@ -0,0 +1,103 @@ +--- +sidebarPos: 30 +sidebarTitle: Repeater +--- + +# Repeater + +The `repeater` input type renders `VInputRepeater`, a dynamic form block that lets users add, remove, duplicate, and drag-to-reorder rows. Each row renders its own sub-form from the `schema` array. It is used for one-to-many relational data or repeatable JSON objects. + +## Hydrate + +**Class:** `RepeaterHydrate` +**Config type:** `repeater` +**Output type:** `input-repeater` → `VInputRepeater` + +The hydrate: +- Sets `root` to `'default'` for `repeater` type, or to the original type name for subtypes (e.g. `json-repeater` sets `root: 'json-repeater'`) +- Sets `singularLabel` from the singular of `label` +- Sets `col.cols: 12` for full-width layout +- When `draggable: true`, defaults `orderKey` to `'position'` +- Auto-resolves foreign key fields in `schema` when `repository`, `model`, or `newConnector` is provided + +## Usage + +### Basic repeater + +```php +[ + 'type' => 'repeater', + 'name' => 'contacts', + 'label' => 'Contacts', + 'singularLabel' => 'Contact', + 'schema' => [ + [ + 'type' => 'text', + 'name' => 'name', + 'label' => 'Name', + ], + [ + 'type' => 'text', + 'name' => 'email', + 'label' => 'Email', + ], + ], +] +``` + +### Draggable with order key + +```php +[ + 'type' => 'repeater', + 'name' => 'items', + 'label' => 'Items', + 'draggable'=> true, + 'orderKey' => 'sort_order', + 'schema' => [ + ['type' => 'text', 'name' => 'title', 'label' => 'Title'], + ], +] +``` + +### Relational repeater with repository + +Provide `repository` to let the hydrate auto-resolve foreign key fields inside `schema`: + +```php +[ + 'type' => 'repeater', + 'name' => 'product_features', + 'label' => 'Features', + 'repository' => \App\Repositories\FeatureRepository::class, + 'schema' => [ + [ + 'type' => 'select', + 'name' => 'feature_id', + 'label' => 'Feature', + // items auto-resolved from repository + ], + [ + 'type' => 'text', + 'name' => 'value', + 'label' => 'Value', + ], + ], +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `autoIdGenerator` | `true` | Auto-assign IDs to new rows | +| `itemValue` | `'id'` | Field used as the row identifier | +| `itemTitle` | `'name'` | Field used as the row display title | +| `root` | `'default'` | Storage root key (`'json-repeater'` for JSON variant) | +| `col.cols` | `12` | Always full width | +| `orderKey` | `'position'` | Sort key (only when `draggable: true`) | + +## See Also + +- [Json Repeater](/guide/form-inputs/input-json-repeater) — Repeater that serialises rows as JSON objects +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-select-scroll.md b/docs/src/pages/guide/form-inputs/input-select-scroll.md new file mode 100644 index 000000000..04136581d --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-select-scroll.md @@ -0,0 +1,87 @@ +--- +sidebarPos: 31 +sidebarTitle: Select Scroll +--- + +# Select Scroll <Badge type="tip" text="^0.9.1" /> + +The `select-scroll` input type renders `VInputSelectScroll`, a virtualised select that loads options on demand as the user scrolls. It is designed for large datasets where loading all items upfront is not practical. + +The default underlying Vuetify component is **`v-autocomplete`**. This can be changed to `v-combobox` or `v-select` via `componentType`. + +> [!NOTE] +> `autocomplete`, `combobox`, and `select` config types also resolve to `input-select-scroll` when `ext: 'scroll'` is set alongside an `endpoint` or `connector`. + +## Hydrate + +**Class:** `SelectScrollHydrate` +**Config type:** `select-scroll` +**Output type:** `input-select-scroll` → `VInputSelectScroll` + +The hydrate: +- Requires an `endpoint` (or `connector` resolved to one) — throws an exception if neither is present and no static `items` are provided +- Sets `componentType` to `v-autocomplete` if not already specified +- Sets `default` to `[]` for multiple-select, `null` for single-select +- Prepends a "Please Select" entry (value `0`) to the loaded items list +- Handles `cascades`: renames relationship keys in the items array to `items` so the cascade mechanism can splice in filtered results + +## Usage + +### Direct `select-scroll` type + +```php +[ + 'type' => 'select-scroll', + 'name' => 'city_id', + 'label' => 'City', + 'connector' => 'Location:City|repository:list', +] +``` + +### Via `ext: 'scroll'` on another select type + +```php +[ + 'type' => 'autocomplete', // or 'select', 'combobox' + 'ext' => 'scroll', + 'name' => 'country_id', + 'label' => 'Country', + 'connector' => 'Location:Country|repository:list', +] +``` + +### Custom component type + +```php +[ + 'type' => 'select-scroll', + 'name' => 'tag_ids', + 'label' => 'Tags', + 'connector' => 'Blog:Tag|repository:list', + 'componentType' => 'v-combobox', + 'multiple' => true, +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | Field used as the option value | +| `itemTitle` | `'name'` | Field displayed for each option | +| `cascadeKey` | `'items'` | Key used when cascading items between inputs | +| `returnObject` | `false` | Return full object instead of just the value | +| `itemsPerPage` | `10` | Number of items fetched per scroll page | +| `multiple` | `false` | Allow multiple selections | +| `componentType` | `'v-autocomplete'` | Underlying Vuetify component | +| `default` | `[]` / `null` | `[]` when `multiple`, `null` otherwise | + +> [!IMPORTANT] +> This component was introduced in [v0.9.1] + +## See Also + +- [Autocomplete](/guide/form-inputs/input-autocomplete) — Uses scroll mode via `ext: 'scroll'` +- [Combobox](/guide/form-inputs/input-combobox) — Uses scroll mode via `ext: 'scroll'` +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract +- [Relationships](/guide/generics/relationships) — Using `connector` to load remote data diff --git a/docs/src/pages/guide/form-inputs/input-spread.md b/docs/src/pages/guide/form-inputs/input-spread.md new file mode 100644 index 000000000..758c725ba --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-spread.md @@ -0,0 +1,68 @@ +--- +sidebarPos: 33 +sidebarTitle: Spread +--- + +# Spread + +The `spread` input type renders `VInputSpread`, a key-value metadata editor. Each row has a **key** field, a **value** field, and a type toggle (string / number / boolean). Users can add, edit, and delete rows; clicking "Save All" commits the changes. Reserved keys (from the model or explicitly configured) are hidden from the editor. + +## Hydrate + +**Class:** `SpreadHydrate` +**Config type:** `spread` +**Output type:** `input-spread` → `VInputSpread` + +The hydrate: +- Sets `type` to `input-spread` and forces `col` to full-width (`cols: 12` across all breakpoints) +- When `_moduleName` / `_routeName` are present: + - Loads `reservedKeys` from `$model->getReservedKeys()` and merges any `spreadable` inputs from the route's schema + - Sets `name` to `$model->getSpreadableSavingKey()` +- Without module context: falls back to `reservedKeys: []` and `name: 'spread_payload'` +- Accepts an inline `scrollable` flag that toggles vertical scrolling on the rows container + +## Usage + +### Auto-resolved (module context) + +```php +[ + 'type' => 'spread', +] +``` + +### Explicit with reserved keys + +```php +[ + 'type' => 'spread', + 'name' => 'meta', + 'reservedKeys' => ['id', 'created_at', 'updated_at'], +] +``` + +### Scrollable with fixed height + +```php +[ + 'type' => 'spread', + 'scrollable' => true, + 'height' => '400px', +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `col` | `{cols:12, sm:12, md:12, lg:12, xl:12}` | Always full width | +| `name` | `'spread_payload'` | Field name (or model's spreadable key) | +| `reservedKeys` | `[]` | Keys hidden/blocked from editing | +| `scrollable` | `false` | Enable vertical scroll on the row list | +| `height` | `'300px'` | Height of the scrollable rows container | + +## See Also + +- [Json](/guide/form-inputs/input-json) — Grouped JSON fields (fixed schema) +- [Repeater](/guide/form-inputs/input-repeater) — Repeatable rows with a defined schema +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-switch.md b/docs/src/pages/guide/form-inputs/input-switch.md new file mode 100644 index 000000000..06811ea8a --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-switch.md @@ -0,0 +1,68 @@ +--- +sidebarPos: 34 +sidebarTitle: Switch +--- + +# Switch + +The `switch` input type renders a toggle switch using Vuetify's `v-switch`. It is functionally similar to [Checkbox](/guide/form-inputs/input-checkbox) but uses a toggle UI instead of a checkbox. The switch is **on by default** (`default: 1`). + +## Hydrate + +**Class:** `SwitchHydrate` +**Config type:** `switch` +**Output type:** `input-switch` → `VInputSwitch` + +The hydrate sets `hideDetails` to `true` and `default` to `1` (on) unless you override them. + +## Usage + +### Basic switch + +```php +[ + 'type' => 'switch', + 'name' => 'is_active', + 'label' => 'Active', +] +``` + +### Default off + +```php +[ + 'type' => 'switch', + 'name' => 'is_featured', + 'label' => 'Featured', + 'default' => 0, +] +``` + +### Custom true/false values + +```php +[ + 'type' => 'switch', + 'name' => 'status', + 'label' => 'Status', + 'trueValue' => 'enabled', + 'falseValue' => 'disabled', +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `color` | `'success'` | Vuetify color for the active (on) state | +| `trueValue` | `1` | Value stored when the switch is on | +| `falseValue` | `0` | Value stored when the switch is off | +| `hideDetails` | `true` | Hide the validation detail row below the switch | +| `default` | `1` | Initial value (on by default) | + +> **Switch vs Checkbox:** Both store a boolean-like value. Use `switch` for prominent on/off settings; use `checkbox` for compact lists of flags. + +## See Also + +- [Checkbox](/guide/form-inputs/input-checkbox) — Same semantics, checkbox UI +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-tag.md b/docs/src/pages/guide/form-inputs/input-tag.md new file mode 100644 index 000000000..02106c8f3 --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-tag.md @@ -0,0 +1,82 @@ +--- +sidebarPos: 35 +sidebarTitle: Tag +--- + +# Tag + +The `tag` input type renders `VInputTag`, a tag picker backed by the Modularous `Tag` entity. It auto-resolves the list of available tags and the update endpoint from the current module/route context, or from an explicit `taggable` model class. + +## Hydrate + +**Class:** `TagHydrate` +**Config type:** `tag` +**Output type:** `input-tag` → `VInputTag` + +The hydrate: + +1. Sets `returnObject: false`, `chips: false`, `multiple: false` as base defaults +2. **Auto-resolution via `_moduleName` / `_routeName`** (set automatically by the module config pipeline): + - Resolves `endpoint` → `{module}.{route}.tags` action URL + - Resolves `updateEndpoint` → `{module}.{route}.tagsUpdate` action URL + - Fetches `items` via `repository->getTags()` + - Sets `taggable` to the repository's model class +3. **Explicit `taggable` class** (when `_moduleName` is absent): + - Fetches items via `$taggableModel->localeTagsList()` (translated) or `Tag::whereNamespace($taggable)->get()` +4. Falls back `updateEndpoint` to `route('admin.tag.update')` if not resolved +5. Sets `default` to the first item's `itemValue` (non-translated, non-empty items only) + +### Translated tags + +When `translated: true` is set, the hydrate adds a `cacheKey` (locale-scoped) and an `updatePayload` carrying the current locale so tag updates are applied to the correct translation. + +## Usage + +### Auto-resolved (standard module context) + +```php +[ + 'type' => 'tag', + 'name' => 'tags', + 'label' => 'Tags', +] +``` + +### Translated tags + +```php +[ + 'type' => 'tag', + 'name' => 'tags', + 'label' => 'Tags', + 'translated' => true, +] +``` + +### Explicit taggable model + +```php +[ + 'type' => 'tag', + 'name' => 'tags', + 'label' => 'Tags', + 'taggable' => \App\Models\Post::class, +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | Field used as the tag value | +| `itemTitle` | `'name'` | Field displayed for each tag | +| `cascadeKey` | `'items'` | Key used when cascading tag lists | +| `returnObject` | `false` | Return tag ID rather than full tag object | +| `chips` | `false` | Display selected tags as chips | +| `multiple` | `false` | Allow multiple tag selection | +| `default` | first item's `itemValue` | Pre-selected tag (non-translated, non-empty only) | + +## See Also + +- [Tagger](/guide/form-inputs/input-tagger) — Free-form tag creation input (different from Tag) +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/input-tagger.md b/docs/src/pages/guide/form-inputs/input-tagger.md new file mode 100644 index 000000000..dbe90683e --- /dev/null +++ b/docs/src/pages/guide/form-inputs/input-tagger.md @@ -0,0 +1,69 @@ +--- +sidebarPos: 36 +sidebarTitle: Tagger +--- + +# Tagger + +The `tagger` input type renders `VInputTagger`, a free-form tag creator and selector built on Vuetify's `v-combobox`. Users can select existing tags (displayed as coloured chips) or type a new value to create one on the fly. Existing tags are editable inline. Unlike [Tag](/guide/form-inputs/input-tag), Tagger manages its own tag entity CRUD (create / rename) rather than just selecting from a pre-existing namespace. + +## Hydrate + +**Class:** `TaggerHydrate` +**Config type:** `tagger` +**Output type:** `input-tagger` → `VInputTagger` + +The hydrate: +- Requires `_moduleName` and `_routeName`; throws if the repository does not use `TagsTrait` +- Sets `fetchEndpoint` and `updateEndpoint` from the module route action URLs +- Loads `items` via `repository->getTags()`, prepending a "Select an option or create one" header +- Each item is enriched with a `color` cycled from the `colors` array +- For translated tags, items are grouped by locale with a header in each group + +## Usage + +### Standard (auto-resolved) + +```php +[ + 'type' => 'tagger', +] +``` + +### Translated tags + +```php +[ + 'type' => 'tagger', + 'translated' => true, +] +``` + +### Custom colors + +```php +[ + 'type' => 'tagger', + 'colors' => ['red', 'blue', 'green', 'yellow'], +] +``` + +## Schema Defaults + +| Key | Default | Description | +|-----|---------|-------------| +| `itemValue` | `'id'` | Field used as the tag value | +| `itemTitle` | `'name'` | Field displayed for each tag | +| `default` | `[]` | No tags selected by default | +| `returnObject` | `false` | Return tag IDs not full objects | +| `label` | `'Tags'` | Field label | +| `name` | `'tags'` | Form field name | +| `multiple` | `true` | Allow multiple tag selections | +| `colors` | `['green','purple','indigo','cyan','teal','orange']` | Chip colour cycle | + +> **Tagger vs Tag:** Use `tagger` when users should be able to **create new tags** from within the form. Use `tag` when tags are managed centrally and users only select from an existing set. + +## See Also + +- [Tag](/guide/form-inputs/input-tag) — Read-only tag selector from a pre-existing namespace +- [Hydrates reference](/system-reference/hydrates) — Resolution table and schema contract diff --git a/docs/src/pages/guide/form-inputs/locale.md b/docs/src/pages/guide/form-inputs/locale.md new file mode 100644 index 000000000..4068c5d4e --- /dev/null +++ b/docs/src/pages/guide/form-inputs/locale.md @@ -0,0 +1,64 @@ +--- +sidebarTitle: Locale +sidebarPos: 23 +--- + +# Locale + +`VInputLocale` is a **translation wrapper** that renders any Vuetify/Modularous input component once per configured language. It handles splitting a single `modelValue` object keyed by locale (`{ en: 'Hello', tr: 'Merhaba' }`) into per-language bindings and merges changes back. There is no corresponding PHP hydrate — the locale wrapping is handled by the hydrate pipeline for translatable fields. + +## Vue Component + +**Registered as:** `VInputLocale` +**File:** `vue/src/js/components/inputs/Locale.vue` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Object` | — | Locale-keyed value object, e.g. `{ en: 'Hello', tr: 'Merhaba' }` | +| `type` | `String` | `'text'` | The underlying component to render per language (e.g. `'v-text-field'`, `'v-select'`, `'v-textarea'`) | +| `attributes` | `Object` | `{}` | Props forwarded to the inner component for each language | +| `initialValues` | `Object` | `{}` | Pre-set values per locale for initialisation | + +### Emits + +Standard `makeInputEmits` — `update:modelValue` with the updated locale object. + +### Usage + +```vue +<VInputLocale + v-model="form.title" + type="v-text-field" + :attributes="{ + label: 'Title', + variant: 'outlined', + rules: ['required'], + }" +/> +``` + +### Behaviour + +- If the application has **multiple languages configured**, one instance of the `type` component is rendered per language. Only the currently active locale is visible (others are `d-none`); a locale chip in the field label lets users switch. +- If only **one language** is configured, the wrapper renders a single instance with no locale UI. +- The locale chip appears inside the field label slot when the field is focused or active. +- `attributes` can contain an `items` object keyed by locale (`{ en: [...], tr: [...] }`) — the wrapper will pass the correct locale's items to each instance automatically. +- `attributes.errorMessages` is distributed per locale from the backend validation response. +- If the application is using a `CustomFormBase` root, all locale variants are rendered simultaneously (`isCustomForm = true`) rather than showing only the active one. + +### Language object shape + +Each language in the application store has: + +```js +{ value: 'en', label: 'English', published: true } +``` + +`published: true` marks the locale as required — fields for unpublished locales are not required even if `rules` includes `'required'`. + +## See Also + +- [Forms overview](/guide/form-inputs/overview) — Schema-driven form architecture +- [Hydrates reference](/system-reference/hydrates) — How translatable fields are hydrated on the backend diff --git a/docs/src/pages/guide/form-inputs/overview.md b/docs/src/pages/guide/form-inputs/overview.md new file mode 100644 index 000000000..393129d3a --- /dev/null +++ b/docs/src/pages/guide/form-inputs/overview.md @@ -0,0 +1,138 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Forms Overview + +Modularous forms are schema-driven. The backend defines inputs; Hydrates transform them into a frontend schema; Vue components consume and render them. + +## What Are Hydrates? + +**Hydrates** are PHP classes that sit between your module config and the frontend form. Each input type has its own Hydrate class that transforms a raw config array into a well-defined schema object the Vue side can render. + +``` +Module config → InputHydrator → XxxHydrate → schema → Vue component +{ type: 'checklist', ... } ↓ { type: 'input-checklist', items: [...], ... } + resolves class name +``` + +### What they do? + +- **Set defaults** — fill in missing keys (e.g. `itemValue: 'id'`, `itemTitle: 'name'`) +- **Change the type** — convert `'checklist'` (config alias) → `'input-checklist'` (frontend type) +- **Load records** — call a repository or connector to populate `items` for selectable inputs +- **Apply rules** — parse `rules` string and add CSS classes (e.g. `required`) +- **Strip backend keys** — remove `route`, `model`, `repository`, `cascades`, `connector` before the schema reaches the frontend + +### Why they exist? + +The Hydrate layer decouples module config syntax from frontend schema syntax. You write concise config in PHP; the Hydrate handles enrichment, fetching, and normalization. The frontend only ever sees a clean, complete schema object. + +### Resolution rule + +`InputHydrator` resolves the class by: `studlyCase($input['type']) . 'Hydrate'` + +| Config `type` | Resolved class | +|---------------|----------------| +| `checklist` | `ChecklistHydrate` | +| `select-scroll` | `SelectScrollHydrate` | +| `filepond-avatar` | `FilepondAvatarHydrate` | + +All Hydrate classes live in `src/Hydrates/Inputs/`. + +## Render Pipeline + +Every Hydrate runs the same pipeline when `render()` is called: + +``` +setDefaults() — apply $requirements defaults +hydrate() — set output type, enrich schema +hydrateRecords() — load items via repository/connector +hydrateRules() — parse rules string, add CSS classes +Arr::except() — strip backend-only keys +``` + +## Form Rendering Flow + +1. **Module config** — define inputs in your module's `config.php` +2. **Controller** — `setupFormSchema()` calls `InputHydrator` before create/edit +3. **Inertia** — hydrated schema + model are passed to the page +4. **Form.vue** — receives `schema` and `modelValue`, uses `useForm` +5. **FormBase** — flattens schema + model into `flatCombinedArraySorted`, iterates over each field +6. **FormBaseField** — renders each field by `obj.schema.type` via `mapTypeToComponent()` +7. **Input components** — receive schema props via `bindSchema(obj)` + +## Key Components + +| Component | Purpose | +|-----------|---------| +| **Form.vue** | Top-level form; validation, submit, schema/model sync | +| **FormBase** | Iterates over flattened schema; grid layout, slots | +| **FormBaseField** | Renders a single field; resolves type → component | +| **CustomFormBase** | Wrapper with app-specific behavior | + +## Schema Structure + +Each field in the schema has: + +- `type` — Resolved to Vue component (e.g. `input-checklist`, `text`, `select`) +- `name` — Field name (binds to model) +- `label` — Display label +- `col` — Grid column span +- `rules` — Validation rules +- `default` — Default value + +**Backend-only keys** (stripped before frontend): `route`, `model`, `repository`, `cascades`, `connector` + +## Config → Component Reference + +| Config type | Hydrate class | Output type | Vue component | +|-------------|---------------|-------------|---------------| +| [assignment](/guide/form-inputs/input-assignment) | AssignmentHydrate | input-assignment | VInputAssignment | +| [autocomplete](/guide/form-inputs/input-autocomplete) | AutocompleteHydrate | select / input-select-scroll | v-autocomplete | +| [browser](/guide/form-inputs/input-browser) | BrowserHydrate | input-browser | VInputBrowser | +| [chat](/guide/form-inputs/input-chat) | ChatHydrate | input-chat | VInputChat | +| [checkbox](/guide/form-inputs/input-checkbox) | CheckboxHydrate | checkbox | v-checkbox | +| [checklist](/guide/form-inputs/input-checklist) | ChecklistHydrate | input-checklist | VInputChecklist | +| [checklist-group](/guide/form-inputs/input-checklist-group) | ChecklistGroupHydrate | input-checklist-group | VInputChecklistGroup | +| [combobox](/guide/form-inputs/input-combobox) | ComboboxHydrate | combobox / input-select-scroll | v-combobox | +| [comparison-table](/guide/form-inputs/input-comparison-table) | ComparisonTableHydrate | input-comparison-table | VInputComparisonTable | +| [date](/guide/form-inputs/input-date) | DateHydrate | input-date | VInputDate | +| [file](/guide/form-inputs/input-file) | FileHydrate | input-file | VInputFile | +| [filepond-avatar](/guide/form-inputs/input-filepond-avatar) | FilepondAvatarHydrate | input-filepond-avatar | VInputFilepondAvatar | +| [form-tabs](/guide/form-inputs/input-form-tabs) | FormTabsHydrate | input-form-tabs | VInputFormTabs | +| [image](/guide/form-inputs/input-image) | ImageHydrate | input-image | VInputImage | +| [json](/guide/form-inputs/input-json) | JsonHydrate | group | (group layout) | +| [json-repeater](/guide/form-inputs/input-json-repeater) | JsonRepeaterHydrate | input-repeater | VInputRepeater | +| [payment-service](/guide/form-inputs/input-payment-service) | PaymentServiceHydrate | input-payment-service | VInputPaymentService | +| [price](/guide/form-inputs/input-price) | PriceHydrate | input-price | VInputPrice | +| [process](/guide/form-inputs/input-process) | ProcessHydrate | input-process | VInputProcess | +| [radio-group](/guide/form-inputs/input-radio-group) | RadioGroupHydrate | input-radio-group | VInputRadioGroup | +| [repeater](/guide/form-inputs/input-repeater) | RepeaterHydrate | input-repeater | VInputRepeater | +| [select-scroll](/guide/form-inputs/input-select-scroll) | SelectScrollHydrate | input-select-scroll | VInputSelectScroll | +| [spread](/guide/form-inputs/input-spread) | SpreadHydrate | input-spread | VInputSpread | +| [switch](/guide/form-inputs/input-switch) | SwitchHydrate | input-switch | VInputSwitch | +| [tag](/guide/form-inputs/input-tag) | TagHydrate | input-tag | VInputTag | +| [tagger](/guide/form-inputs/input-tagger) | TaggerHydrate | input-tagger | VInputTagger | + +## FormBase Slots + +FormBase provides slots for customization: + +- `form-top`, `form-bottom` — Form-level +- `{type}-top`, `{type}-bottom` — By schema type (e.g. `input-checklist-top`) +- `{key}-top`, `{key}-bottom` — By field name +- `{type}-item`, `{key}-item` — Override field rendering + +## Adding a New Input + +1. **PHP** — Create `src/Hydrates/Inputs/{Studly}Hydrate.php` extending `InputHydrate` + - Set `$input['type'] = 'input-{kebab}'` in `hydrate()` + - Define `$requirements` for default schema keys +2. **Vue** — Create `vue/src/js/components/inputs/{Studly}.vue` + - Use `useInput`, `makeInputProps`, `makeInputEmits` from `@/hooks` + - Component auto-registers as `VInput{Studly}` via `includeFormInputs` glob +3. **Registry** (optional) — Add to `hydrateTypeMap` in `registry.js` for explicit mapping + +See the [create-input-hydrate](/guide/console/generators/create-input-hydrate) and [create-vue-input](/guide/console/generators/create-vue-input) commands. diff --git a/docs/src/pages/guide/form-inputs/phone.md b/docs/src/pages/guide/form-inputs/phone.md new file mode 100644 index 000000000..7bdb9094e --- /dev/null +++ b/docs/src/pages/guide/form-inputs/phone.md @@ -0,0 +1,70 @@ +--- +sidebarTitle: Phone +sidebarPos: 25 +--- + +# Phone + +`VInputPhone` is an international phone number input combining a country flag/dial-code selector (`v-autocomplete`) with a text field for the number. It validates the number using `awesome-phonenumber` and auto-detects the country from the number prefix. There is no corresponding PHP hydrate — this component is used directly in Vue templates. + +## Vue Component + +**Registered as:** `VInputPhone` +**File:** `vue/src/js/components/inputs/Phone.vue` + +The `modelValue` is always stored in the **international** format (e.g. `+905551234567`) when the number is valid; national format otherwise. + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `String` | — | Phone number string (v-model) | +| `disabled` | `Boolean` | `false` | Disables both the country selector and the number field | +| `defaultCountry` | `String` | `'TR'` | ISO2 country code pre-selected on mount | +| `preferredCountries` | `Array` | `[]` | ISO2 codes listed at the top of the country dropdown | +| `onlyCountries` | `Array` | `[]` | Restrict the dropdown to these ISO2 codes only | +| `ignoredCountries` | `Array` | `[]` | Exclude these ISO2 codes from the dropdown | +| `allCountries` | `Array` | *(full list)* | Override the full country list | +| `mode` | `String` | `'national'` | Output mode: `'national'` or `'international'` | +| `disabledFetchingCountry` | `Boolean` | `false` | Skip auto-detecting the user's country via IP | +| `invalidMsg` | `String` | `'Invalid phone number'` | Validation error message shown for invalid numbers | + +### Emits + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `String` | Formatted phone number | +| `validate` | `Object` | `awesome-phonenumber` result object with `.valid`, `.number`, `.country` | +| `country-changed` | `Object` | Active country object `{ iso2, dialCode, name }` | +| `input` / `blur` / `focus` / `change` | — | Standard field events | + +### Usage + +```vue +<VInputPhone + v-model="form.phone" + defaultCountry="TR" + :preferredCountries="['TR', 'US', 'DE']" + @validate="onValidate" +/> +``` + +```vue +<!-- Restrict to a subset of countries --> +<VInputPhone + v-model="form.phone" + :onlyCountries="['TR', 'US', 'GB', 'DE', 'FR']" +/> +``` + +### Behaviour + +- The country is **auto-detected** when the number starts with `+`; the flag and dial code update automatically. +- A built-in `phoneRule` is appended to `rules` and fires `awesome-phonenumber` validation on every input. +- If `disabledFetchingCountry` is `false`, the component attempts an IP-based country lookup on mount to pre-select the user's country. +- `preferredCountries` are listed first in the dropdown, separated from the rest by a divider. +- The text field placeholder updates to a sample number for the active country. + +## See Also + +- [Forms overview](/guide/form-inputs/overview) — Schema-driven form architecture diff --git a/docs/src/pages/guide/form-inputs/select-tag.md b/docs/src/pages/guide/form-inputs/select-tag.md new file mode 100644 index 000000000..347305dcd --- /dev/null +++ b/docs/src/pages/guide/form-inputs/select-tag.md @@ -0,0 +1,62 @@ +--- +sidebarTitle: Select Tag +sidebarPos: 32 +--- + +# Select Tag + +`VSelectTag` is a multi-select autocomplete for tags backed by a remote endpoint. Users can select existing tags (displayed as chips) and optionally type new values that don't yet exist in the list. There is no corresponding PHP hydrate — see [Tagger](/guide/form-inputs/input-tagger) for the hydrate-backed alternative. + +## Vue Component + +**Registered as:** `VSelectTag` +**File:** `vue/src/js/components/inputs/SelectTag.vue` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Array` | — | Array of selected tag values (v-model) | +| `endpoint` | `String` | — | API URL to fetch tag options from (paginated) | +| `selected` | `Array` | — | Initial selected items (synced to `modelValue`) | + +Plus all standard `makeInputProps()` props (`label`, `name`, `rules`, `disabled`, etc.). + +### Emits + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `String` | Comma-separated selected values | +| `change` | `String` | Same as `update:modelValue` | + +### Usage + +```vue +<VSelectTag + v-model="form.tag_ids" + endpoint="/api/tags" + label="Tags" +/> +``` + +### Behaviour + +- Tags are loaded from `endpoint` with `?page=N` pagination. Subsequent pages are fetched automatically if the currently selected values aren't found in the initial page. +- The component renders a hidden `<input name="tags">` that is kept in sync with the selection, for standard form submission compatibility. +- **Custom values**: When the user types a value that doesn't exist in the fetched list, it is temporarily added as a selectable option. On selection it is committed; if the user clears the search without selecting, the temporary entry is removed. +- The emitted `modelValue` is a comma-separated **string** of selected values (not an array), suitable for query string or hidden field use. + +## Difference from Tagger + +| Feature | SelectTag | [Tagger](/guide/form-inputs/input-tagger) | +|---------|-----------|--------| +| Hydrate | None | `TaggerHydrate` | +| Tag creation | Client-side temporary | Saves to DB on type | +| Colour chips | No | Yes (server-side colours) | +| Rename inline | No | Yes | + +## See Also + +- [Tagger](/guide/form-inputs/input-tagger) — Hydrate-backed tag creator with DB persistence +- [Tag](/guide/form-inputs/input-tag) — Read-only tag selector from a pre-existing namespace +- [Forms overview](/guide/form-inputs/overview) — Schema-driven form architecture diff --git a/docs/src/pages/guide/form-inputs/terms-checkbox.md b/docs/src/pages/guide/form-inputs/terms-checkbox.md new file mode 100644 index 000000000..bc3a2717d --- /dev/null +++ b/docs/src/pages/guide/form-inputs/terms-checkbox.md @@ -0,0 +1,79 @@ +--- +sidebarTitle: Terms Checkbox +sidebarPos: 37 +--- + +# Terms Checkbox + +`VInputTermsCheckbox` is a checkbox that gates agreement behind a terms modal. The user must open and read the terms/conditions dialog before the checkbox can be checked. It is used on registration and payment flows. There is no corresponding PHP hydrate. + +## Vue Component + +**Registered as:** `VInputTermsCheckbox` +**File:** `vue/src/js/components/inputs/TermsCheckbox.vue` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | — | — | Current checkbox value (v-model) | +| `label` | `String` | `'I agree to the terms and conditions'` | Default label shown next to the checkbox | +| `htmlLabel` | `String` | — | Raw HTML label; overrides `label` when set | +| `terms` | `String` | *(i18n `authentication.terms-policy`)* | HTML content shown in the Terms modal tab | +| `conditions` | `String` | *(i18n `authentication.conditions-policy`)* | HTML content shown in the Conditions modal tab | +| `trueValue` | `Boolean \| String \| Number` | `1` | Value stored when checked | +| `falseValue` | `Boolean \| String \| Number` | `0` | Value stored when unchecked | +| `noCheckbox` | `Boolean` | `false` | Hides the checkbox element (keeps the label and modal) | +| `noHandleClick` | `Boolean` | `false` | Disables the forced-read flow; allows direct toggle | + +### Emits + +Standard `makeInputEmits` — `update:modelValue`. + +### Slots + +| Slot | Scope | Description | +|------|-------|-------------| +| `label` | `labelScope` | Override the entire label content | + +### Usage + +```vue +<!-- Default — forces user to read terms before checking --> +<VInputTermsCheckbox + v-model="form.agreed" + :terms="termsHtml" + :conditions="conditionsHtml" +/> +``` + +```vue +<!-- Custom HTML label with links --> +<VInputTermsCheckbox + v-model="form.agreed" + htmlLabel="I agree to the <a href='/terms'>Terms</a> and <a href='/privacy'>Privacy Policy</a>" +/> +``` + +```vue +<!-- Hidden checkbox (agreement implied by another action) --> +<VInputTermsCheckbox + v-model="form.agreed" + :noCheckbox="true" + :noHandleClick="true" +/> +``` + +### Behaviour + +- **First click**: Opens the terms modal instead of toggling the checkbox. Once opened, `isRead` is set to `true`. +- **Subsequent clicks**: Toggles the checkbox normally. +- The modal has two sections accessible via the default label — **Terms** and **Conditions** — each opening the same modal with the respective content. +- The modal's confirm button ("I agree") sets the checkbox to `trueValue` and closes the dialog. +- A validation rule is applied after 2 interaction attempts: the checkbox must be checked (`trueValue`) to pass. +- `noHandleClick` bypasses the forced-read flow entirely — the checkbox behaves like a standard `v-checkbox`. + +## See Also + +- [Checkbox](/guide/form-inputs/input-checkbox) — Simple boolean toggle without terms flow +- [Forms overview](/guide/form-inputs/overview) — Schema-driven form architecture diff --git a/docs/src/pages/guide/generics/allowable.md b/docs/src/pages/guide/generics/allowable.md index b601a5afe..be3ef0766 100644 --- a/docs/src/pages/guide/generics/allowable.md +++ b/docs/src/pages/guide/generics/allowable.md @@ -1,11 +1,11 @@ --- outline: deep -sidebarPos: 6 +sidebarPos: 2 --- # Allowable -`Modularity` provides an `Allowable` trait that automatically handles role-based access control for arrays and collections. This trait integrates seamlessly with Laravel's authentication system to filter items based on user roles and permissions, ensuring only authorized content is displayed to users. +`Modularous` provides an `Allowable` trait that automatically handles role-based access control for arrays and collections. This trait integrates seamlessly with Laravel's authentication system to filter items based on user roles and permissions, ensuring only authorized content is displayed to users. ## How It Works diff --git a/docs/src/pages/guide/generics/file-storage-with-filepond.md b/docs/src/pages/guide/generics/file-storage-with-filepond.md index ff81e0af9..9213a370e 100644 --- a/docs/src/pages/guide/generics/file-storage-with-filepond.md +++ b/docs/src/pages/guide/generics/file-storage-with-filepond.md @@ -1,19 +1,18 @@ --- outline: deep -sidebarPos: 5 - +sidebarPos: 3 --- # File Storage with Filepond -`Modularity` provides two different file storage functionality, with file library method and filepond. These two systems, differentiate over `file - fileable object` relationship and input component used over forms. This documentation will only cover the filepond mechanism. +`Modularous` provides two different file storage functionality, with file library method and filepond. These two systems, differentiate over `file - fileable object` relationship and input component used over forms. This documentation will only cover the filepond mechanism. ## Storage Mechanism -Filepond storage mechanism is design based on [FilePond Vue Component Docs](https://pqina.nl/filepond/docs/api/server/), which requires and serves `temporary asset` processing. For an example, let's say project have system users and users can upload their avatar(s). -* When a file is uplaoded through the FilePond interface, it is sent to our backend via a secure API endpoint. +Filepond storage mechanism is designed based on [FilePond Vue Component Docs](https://pqina.nl/filepond/docs/api/server/), which requires and serves `temporary asset` processing. For an example, let's say project have system users and users can upload their avatar(s). +* When a file is uploaded through the FilePond interface, it is sent to our backend via a secure API endpoint. * Then, our `FilePondManager` processes the file upload request, performs necessary validations and stores the file in temporary file storage path and file data in `temporary file table`. -* During this stage, the file is cached to echance performance and allow for any further processing or validation checks. And it is ready for permanent storage +* During this stage, the file is cached to enhance performance and allow for any further processing or validation checks. And it is ready for permanent storage * Once the associated model form is confirmed or saved, the file is then moved from the temporary cache to its permanent storage location and a file object will be created on the permanent asset table. @@ -32,3 +31,122 @@ Regarding the object relations, `modularity's filepond` offers `one to many poly In order to implement and use filepond on file storage, see [Files and Media](/guide/module-features/files-and-media) for the Filepond triple pattern. ::: + +## Quick Setup + +Three steps wire up Filepond for any entity: + +### 1. Model — `HasFileponds` trait + +```php +use Unusualify\Modularity\Entities\Traits\HasFileponds; + +class Ticket extends Model +{ + use HasFileponds; +} +``` + +Adds a `morphMany(Filepond::class, 'filepondable')` relation and accessors: `fileponds()`, `getFileponds()`, `hasFilepond()`. + +### 2. Repository — `FilepondsTrait` + +```php +use Unusualify\Modularity\Repositories\Traits\FilepondsTrait; + +class TicketRepository extends Repository +{ + use FilepondsTrait; +} +``` + +Handles moving files from the temporary table to permanent storage on save, and reverting on rollback. + +### 3. Route config — `filepond` input + +```php +'inputs' => [ + [ + 'type' => 'filepond', + 'name' => 'attachments', + 'max' => 5, + 'acceptedExtensions' => ['pdf', 'doc', 'docx', 'png', 'jpg'], + ], +] +``` + +That's it. The hydrate (`FilepondHydrate`) translates this to an `input-filepond` component with the correct `process`, `revert`, and `load` endpoints. + +--- + +## Runtime Examples + +### Accessing files on a model + +```php +$ticket = Ticket::find(1); + +// All fileponds regardless of role +$ticket->fileponds; + +// Fileponds for a specific role/locale +$ticket->getFileponds('attachments'); + +// Boolean check +if ($ticket->hasFilepond('attachments')) { + // ... +} +``` + +### Iterating in Blade / Vue + +```blade +@foreach ($ticket->getFileponds('attachments') as $file) + <a href="{{ $file->url }}" download>{{ $file->name }}</a> +@endforeach +``` + +### Avatar upload (single file) + +```php +'inputs' => [ + [ + 'type' => 'filepond', + 'name' => 'avatar', + 'max' => 1, + 'acceptedExtensions' => ['png', 'jpg', 'jpeg', 'webp'], + ], +] +``` + +Use `input-filepond-avatar` if you want the round avatar UI preset (see [Form Inputs](/guide/form-inputs/overview)). + +### Attachments with size limit + +```php +[ + 'type' => 'filepond', + 'name' => 'documents', + 'max' => 10, + 'maxFileSize' => '5MB', + 'acceptedExtensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx'], +] +``` + +--- + +## Filepond vs Files vs Images + +| Use Case | Pick | +|----------|------| +| User avatar, ticket attachments, any direct 1:N upload | **Filepond** (`HasFileponds`) | +| Shared document library reused across records | **Files** (`HasFiles`) | +| Images with cropping, role/locale variants, transformations | **Media** (`HasImages`) | + +Filepond is the lightest option — no library, no pivot metadata beyond the polymorphic link. See [Files and Media](/guide/module-features/files-and-media) for the full comparison. + +## Related + +- [Files and Media](/guide/module-features/files-and-media) — the triple pattern in full +- [FilepondHydrate](/system-reference/backend/overview) — schema transformation +- [Filepond entity](/system-reference/backend/overview) — model, columns, relationships diff --git a/docs/src/pages/guide/generics/index.md b/docs/src/pages/guide/generics/index.md deleted file mode 100644 index 45428d729..000000000 --- a/docs/src/pages/guide/generics/index.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -sidebarPos: 0 -sidebarTitle: Generics Overview ---- - -# Generics Overview - -Generics are cross-cutting concerns and foundational patterns used across Modularity modules. - -| Page | Description | -|------|-------------| -| [Allowable](/guide/generics/allowable) | Allowable feature | -| [Responsive Visibility](/guide/generics/responsive-visibility) | Responsive visibility | -| [File Storage with Filepond](/guide/generics/file-storage-with-filepond) | Filepond integration for file storage | -| [Relationships](/guide/generics/relationships) | Eloquent relationships, model and route relationships | diff --git a/docs/src/pages/guide/generics/overview.md b/docs/src/pages/guide/generics/overview.md new file mode 100644 index 000000000..080cb8b02 --- /dev/null +++ b/docs/src/pages/guide/generics/overview.md @@ -0,0 +1,178 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +outline: deep +--- + +# Generics Overview + +**Generics** are cross-cutting utilities and patterns that any module can use. Unlike [Module Features](/guide/module-features/overview) (which follow the Entity + Repository + Hydrate triple pattern), generics are lightweight traits and conventions applied wherever they're useful — controllers, arrays, route configs, models. + +## At a Glance + +| Generic | Layer | Solves | Entry point | +|---------|-------|--------|-------------| +| [Allowable](./allowable) | Controller trait | Role-based filtering of arrays / collections (menus, actions, widgets) | `use Allowable` + `allowedRoles` key | +| [Responsive Visibility](./responsive-visibility) | Controller trait | Show/hide array items per Vuetify breakpoint | `use ResponsiveVisibility` + `responsive` key | +| [File Storage with Filepond](./file-storage-with-filepond) | Model trait + input | One-to-many polymorphic file uploads via FilePond | `use HasFileponds` + `type: filepond` | +| [Relationships](./relationships) | CLI + runtime | Eloquent relationship generation and conventions | `--relationships` on `make:model` / `make:route` | + +## Decision Guide + +**Need to filter a list by user role?** → [Allowable](./allowable) + +**Need to hide menu items on mobile?** → [Responsive Visibility](./responsive-visibility) + +**Need users to upload files (avatar, attachments)?** → [File Storage with Filepond](./file-storage-with-filepond) + +**Defining a new module and its relations?** → [Relationships](./relationships) + +**Need the full feature triple (Entity + Repository + Hydrate)?** → Check [Module Features](/guide/module-features/overview) instead. + +--- + +## Quick Examples + +### Allowable — Filter by Role + +```php +use Unusualify\Modularity\Traits\Allowable; + +class NavigationController extends Controller +{ + use Allowable; + + public function items() + { + return $this->getAllowableItems([ + ['title' => 'Home', 'route' => 'home'], + ['title' => 'Admin', 'route' => 'admin', 'allowedRoles' => ['admin']], + ]); + } +} +``` + +Items without `allowedRoles` are public. See [Allowable](./allowable) for closures, guards, and custom search keys. + +--- + +### Responsive Visibility — Breakpoint Control + +```php +use Unusualify\Modularity\Traits\ResponsiveVisibility; + +class MenuController extends Controller +{ + use ResponsiveVisibility; + + public function items() + { + return $this->getResponsiveItems([ + ['title' => 'Desktop Search', 'responsive' => ['hideBelow' => 'md']], + ['title' => 'Mobile Menu', 'responsive' => ['hideAbove' => 'md']], + ]); + } +} +``` + +Applies Vuetify `d-{breakpoint}-*` classes. See [Responsive Visibility](./responsive-visibility) for all modifiers (`hideOn`, `showOn`, `hideBelow`, `hideAbove`, `breakpoints`). + +--- + +### Filepond — Upload in Three Lines + +Model: + +```php +use Unusualify\Modularity\Entities\Traits\HasFileponds; + +class Ticket extends Model +{ + use HasFileponds; +} +``` + +Route config: + +```php +'inputs' => [ + ['type' => 'filepond', 'name' => 'attachments', 'max' => 5], +] +``` + +Repository: + +```php +use Unusualify\Modularity\Repositories\Traits\FilepondsTrait; + +class TicketRepository extends Repository +{ + use FilepondsTrait; +} +``` + +See [File Storage with Filepond](./file-storage-with-filepond) for storage mechanics and [Files and Media](/guide/module-features/files-and-media) for the full triple pattern. + +--- + +### Relationships — Define at Generation Time + +```bash +# Model-level (adds method to parent model) +php artisan modularity:make:model Package Billing \ + --relationships="belongsToMany:Feature" + +# Route-level (creates pivot + migration + reverse relation) +php artisan modularity:make:route Billing packages \ + --relationships="Feature:belongsToMany,position:integer:unsigned:index" +``` + +See [Relationships](./relationships) for full grammar, field types, and modifiers. + +--- + +## Composing Generics + +Generics are independent, so you can stack them. The most common combo is **Allowable + ResponsiveVisibility** on the same menu: + +```php +use Unusualify\Modularity\Traits\{Allowable, ResponsiveVisibility}; + +class MenuController extends Controller +{ + use Allowable, ResponsiveVisibility; + + public function items() + { + $items = [ + [ + 'title' => 'Admin Panel', + 'allowedRoles' => ['admin'], + 'responsive' => ['hideBelow' => 'md'], + ], + ]; + + // Order matters: filter by role first, then apply CSS classes + return $this->getResponsiveItems( + $this->getAllowableItems($items) + ); + } +} +``` + +--- + +## Where Generics Live + +| Generic | FQCN | +|---------|------| +| Allowable | `Unusualify\Modularity\Traits\Allowable` | +| ResponsiveVisibility | `Unusualify\Modularity\Traits\ResponsiveVisibility` | +| HasFileponds | `Unusualify\Modularity\Entities\Traits\HasFileponds` | +| FilepondsTrait | `Unusualify\Modularity\Repositories\Traits\FilepondsTrait` | + +## Related + +- [Module Features](/guide/module-features/overview) — full feature patterns (Entity + Repository + Hydrate) +- [Files and Media](/guide/module-features/files-and-media) — Files / Images / Filepond side-by-side +- [Hydrates](/system-reference/hydrates) — how form schema is generated diff --git a/docs/src/pages/guide/generics/relationships.md b/docs/src/pages/guide/generics/relationships.md index fcfef037c..74ec37238 100644 --- a/docs/src/pages/guide/generics/relationships.md +++ b/docs/src/pages/guide/generics/relationships.md @@ -1,12 +1,11 @@ --- outline: deep -sidebarPos: 1 - +sidebarPos: 4 --- # Relationships -All of Modularity's relationships rely on [Laravel Eloquent Relationships](https://laravel.com/docs/eloquent-relationships). We suppose that you know this relationship concepts. At now, we provide many of these as following: +All of Modularous relationships rely on [Laravel Eloquent Relationships](https://laravel.com/docs/eloquent-relationships). We suppose that you know these relationship concepts. At now, we provide many of these as following: - hasOne - belongsTo @@ -20,9 +19,9 @@ All of Modularity's relationships rely on [Laravel Eloquent Relationships](https - morphedByMany ## Get Started -We'll be explaining how to use this relationships on making and creating sources. We have some critical concepts for maintainability of system infrastructure. You should think each creation as a step or stage. Every stage interests both previous and next stage. You must follow instructions in the way we pointed while creating the system skeleton. +We'll be explaining how to use these relationships on making and creating sources. We have some critical concepts for maintainability of system infrastructure. You should think each creation as a step or stage. Every stage interests both previous and next stage. You must follow instructions in the way we pointed while creating the system skeleton. -Modularity System has multiple relationship constructor mechanism. While making model and creating a module route, you can define relationships. But the **make:route** command get relationships schema and convert it the way adapted **make:model** _--relationships_. **|** delimeter can be considered array explode operator. For example, basically --relationships="name1:arg1|name2:arg2" option points stuff as following +Modularous System has multiple relationship constructor mechanism. While making model and creating a module route, you can define relationships. But the **make:route** command get relationships schema and convert it the way adapted **make:model** _--relationships_. **|** delimeter can be considered array explode operator. For example, basically --relationships="name1:arg1|name2:arg2" option points stuff as following ``` php [ name1 => [ @@ -87,7 +86,7 @@ Here are two valid examples of the `--relationships` argument: ## Route Relationships -Route relationships parameter more complex than model relationship, both makes what model relationships does and other necessary system infrastructure elements. Pivot model and migration generating, chaining methods for sometimes pivot table column fields, reverse relationships to related models. The syntax is more similar to --schema than --relationships option of the model command. +Route relationships parameter is more complex than model relationships, as it does what model relationships do and also handles other necessary system infrastructure elements. Pivot model and migration generating, chaining methods for sometimes pivot table column fields, reverse relationships to related models. The syntax is more similar to --schema than --relationships option of the model command. <!-- "Route Relationships" => "package_feature:belongsToMany,position:integer:unsigned:index,active:string:default(true)|package_language:belongsToMany" --> ### Synopsis @@ -129,4 +128,162 @@ Here are two valid examples of the `--relationships` argument: ```ini --relationships="PackageFeature:belongsToMany,position:integer:unsigned:index,active:string:default(true)|PackageLanguage:morphToMany" ``` + +--- + +## Runtime Usage + +Once generated, relationships behave like any Laravel Eloquent relationship. The examples below show the most common access patterns. + +### belongsToMany with pivot fields + +```php +$package = Package::find(1); + +// Attach with pivot fields +$package->features()->attach($featureId, [ + 'position' => 3, + 'active' => true, +]); + +// Sync (replace the whole set) +$package->features()->sync([ + 1 => ['position' => 0, 'active' => true], + 2 => ['position' => 1, 'active' => false], +]); + +// Read pivot fields +foreach ($package->features as $feature) { + $feature->pivot->position; + $feature->pivot->active; +} + +// Ordered by pivot field +$ordered = $package->features()->orderBy('pivot_position')->get(); +``` + +### morphToMany / morphMany + +```php +// morphToMany — polymorphic many-to-many +$post->tags()->attach($tagId); +$tag->posts; // reverse side + +// morphMany — polymorphic one-to-many +$user->notifications; +$notification->notifiable; // back to owner +``` + +### hasManyThrough + +```php +// Country → Post (through User) +$country->posts; // all posts by users in this country +``` + +--- + +## Real-World Examples + +### 1. Simple many-to-many (Package ↔ Feature) + +```bash +php artisan modularity:make:route Billing packages \ + --relationships="Feature:belongsToMany" +``` + +Creates: pivot table `package_feature`, `features()` on Package, `packages()` on Feature. + +```php +$package->features; // Collection<Feature> +$feature->packages; // Collection<Package> +``` + +### 2. Ordered many-to-many with pivot fields + +```bash +php artisan modularity:make:route Billing packages \ + --relationships="Feature:belongsToMany,position:integer:unsigned:index,active:string:default(true)" +``` + +Generates pivot `package_feature` with `position` (indexed) and `active` columns. + +```php +$package->features() + ->wherePivot('active', true) + ->orderBy('pivot_position') + ->get(); +``` + +### 3. Polymorphic tagging + +```bash +php artisan modularity:make:route Content tags \ + --relationships="Taggable:morphToMany" +``` + +Any model can then use the polymorphic side: + +```php +class Post extends Model +{ + public function tags() + { + return $this->morphToMany(Tag::class, 'taggable'); + } +} + +$post->tags()->attach($tagIds); +``` + +### 4. Model-only relation (no pivot generation) + +```bash +php artisan modularity:make:model Comment Blog \ + --relationships="belongsToMany:Post" +``` + +Adds only the method on the Comment model; does **not** generate a pivot migration. Use when the pivot already exists or is managed elsewhere. + +--- + +## Config-Driven Relationships + +Modularous route configs can declare relationships alongside inputs. The hydrate layer picks them up when building form schema: + +```php +// Route config +[ + 'name' => 'packages', + 'inputs' => [ + ['type' => 'text', 'name' => 'title'], + [ + 'type' => 'input-relationships', + 'name' => 'features', + 'relation' => 'belongsToMany', + 'related' => Feature::class, + 'pivot' => ['position' => 'integer'], + ], + ], +] +``` + +See [input-relationships](/guide/form-inputs/input-relationships) for the full input component and [Relationships entity traits](/system-reference/backend/overview) for how `HasAssociations`, `HasChildren`, `HasRelatedItems` expose typed relations. + +--- + +## Common Pitfalls + +| Issue | Fix | +|-------|-----| +| Pivot field not readable | Call `->withPivot('field')` on the relation or define pivot fields on generation | +| Ordering broken on `belongsToMany` | Use `orderBy('pivot_<field>')`, not `orderBy('<field>')` | +| Reverse relation missing | Use `make:route` (generates both sides), not `make:model` (adds one method only) | +| morphToMany table name wrong | Singular relation name (e.g. `taggable`), matches the `{name}_type` / `{name}_id` columns | + +## Related + +- [Module Features](/guide/module-features/overview) — feature patterns built on relationships +- [Entities](/system-reference/backend/overview) — model hierarchy and relationship traits +- [input-relationships](/guide/form-inputs/input-relationships) — the form input that renders a relation picker diff --git a/docs/src/pages/guide/generics/responsive-visibility.md b/docs/src/pages/guide/generics/responsive-visibility.md index b8f16d1a5..7b36dbf19 100644 --- a/docs/src/pages/guide/generics/responsive-visibility.md +++ b/docs/src/pages/guide/generics/responsive-visibility.md @@ -1,11 +1,11 @@ --- outline: deep -sidebarPos: 6 +sidebarPos: 5 --- # Responsive Visibility -`Modularity` provides a `ResponsiveVisibility` trait that automatically handles responsive display classes for arrays and collections. This trait integrates seamlessly with Vuetify's responsive utility classes to control when UI elements are shown or hidden based on screen size breakpoints. +`Modularous` provides a `ResponsiveVisibility` trait that automatically handles responsive display classes for arrays and collections. This trait integrates seamlessly with Vuetify's responsive utility classes to control when UI elements are shown or hidden based on screen size breakpoints. ## How It Works @@ -468,4 +468,4 @@ try { } ``` -The trait ensures type safety and provides clear error messages when invalid parameters are provided. \ No newline at end of file +The trait ensures type safety and provides clear error messages when invalid parameters are provided. diff --git a/docs/src/pages/guide/index.md b/docs/src/pages/guide/index.md deleted file mode 100644 index bebfab644..000000000 --- a/docs/src/pages/guide/index.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -sidebarPos: 1 -sidebarTitle: Guide Overview ---- - -# Guide - -This section covers UI components, forms, and tables used in Modularity's admin panel. - -## Components - -| Page | Description | -|------|-------------| -| [Data Tables](/guide/components/data-tables) | Table component, table options, customization | -| [Forms](/guide/components/forms) | Form architecture, FormBase, schema flow | -| [Input Form Groups](/guide/components/input-form-groups) | Form groups and layout | -| [Input Checklist Group](/guide/components/input-checklist-group) | Checklist group input | -| [Input Comparison Table](/guide/components/input-comparison-table) | Comparison table input | -| [Input Filepond](/guide/components/input-filepond) | Filepond file upload | -| [Input Radio Group](/guide/components/input-radio-group) | Radio group input | -| [Input Select Scroll](/guide/components/input-select-scroll) | Scrollable select input | -| [Tab Groups](/guide/components/tab-groups) | Tab groups for forms | -| [Tabs](/guide/components/tabs) | Tab component | -| [Stepper Form](/guide/components/stepper-form) | Stepper form component | - -## Architecture Reference - -For system internals (Hydrates, Repositories, schema flow), see [System Reference](/system-reference/). diff --git a/docs/src/pages/guide/module-features/assignable.md b/docs/src/pages/guide/module-features/assignable.md index e8d71f4ea..2fa64ef3d 100644 --- a/docs/src/pages/guide/module-features/assignable.md +++ b/docs/src/pages/guide/module-features/assignable.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 7 +sidebarPos: 2 --- # Assignable diff --git a/docs/src/pages/guide/module-features/authorizable.md b/docs/src/pages/guide/module-features/authorizable.md index 92c5f25d1..f5ad98997 100644 --- a/docs/src/pages/guide/module-features/authorizable.md +++ b/docs/src/pages/guide/module-features/authorizable.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 9 +sidebarPos: 3 --- # Authorizable diff --git a/docs/src/pages/guide/module-features/chatable.md b/docs/src/pages/guide/module-features/chatable.md index 96a4d9ccf..08d5b6035 100644 --- a/docs/src/pages/guide/module-features/chatable.md +++ b/docs/src/pages/guide/module-features/chatable.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 8 +sidebarPos: 4 --- # Chatable diff --git a/docs/src/pages/guide/module-features/creator.md b/docs/src/pages/guide/module-features/creator.md index 8b4598c25..c73022568 100644 --- a/docs/src/pages/guide/module-features/creator.md +++ b/docs/src/pages/guide/module-features/creator.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 10 +sidebarPos: 5 --- # Creator diff --git a/docs/src/pages/guide/module-features/files-and-media.md b/docs/src/pages/guide/module-features/files-and-media.md index 009dc1d89..6b52c4a7e 100644 --- a/docs/src/pages/guide/module-features/files-and-media.md +++ b/docs/src/pages/guide/module-features/files-and-media.md @@ -1,5 +1,5 @@ --- -sidebarPos: 2 +sidebarPos: 6 --- # Files and Media diff --git a/docs/src/pages/guide/module-features/index.md b/docs/src/pages/guide/module-features/overview.md similarity index 97% rename from docs/src/pages/guide/module-features/index.md rename to docs/src/pages/guide/module-features/overview.md index 0938b660f..3ce79b095 100644 --- a/docs/src/pages/guide/module-features/index.md +++ b/docs/src/pages/guide/module-features/overview.md @@ -1,13 +1,13 @@ --- -sidebarPos: 0 -sidebarTitle: Module Features Overview +sidebarPos: 1 +sidebarTitle: Overview --- # Module Features Overview -Modularity module features follow a **triple pattern**: Entity trait + Repository trait + Hydrate. Each layer handles a specific concern. +Modularous module features follow a **triple pattern**: Entity trait + Repository trait + Hydrate. Each layer handles a specific concern. -See [Features Pattern](/system-reference/features) for the full pattern explanation. For generics (Allowable, Relationships, Files and Media, etc.), see [Generics](/guide/generics/). +See [Features Pattern](/system-reference/features) for the full pattern explanation. For generics (Allowable, Relationships, Files and Media, etc.), see [Generics](/guide/generics/overview). | Layer | Location | Purpose | |-------|----------|---------| diff --git a/docs/src/pages/guide/module-features/payment.md b/docs/src/pages/guide/module-features/payment.md index 26bcf0c14..4812b7c01 100644 --- a/docs/src/pages/guide/module-features/payment.md +++ b/docs/src/pages/guide/module-features/payment.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 19 +sidebarPos: 7 --- # Payment diff --git a/docs/src/pages/guide/module-features/position.md b/docs/src/pages/guide/module-features/position.md index 549993949..8d1e88a0c 100644 --- a/docs/src/pages/guide/module-features/position.md +++ b/docs/src/pages/guide/module-features/position.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 11 +sidebarPos: 8 --- # Position diff --git a/docs/src/pages/guide/module-features/processable.md b/docs/src/pages/guide/module-features/processable.md index e091a24e4..1e53e0d3e 100644 --- a/docs/src/pages/guide/module-features/processable.md +++ b/docs/src/pages/guide/module-features/processable.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 17 +sidebarPos: 9 --- # Processable diff --git a/docs/src/pages/guide/module-features/repeaters.md b/docs/src/pages/guide/module-features/repeaters.md index 46ae2c34e..643f58105 100644 --- a/docs/src/pages/guide/module-features/repeaters.md +++ b/docs/src/pages/guide/module-features/repeaters.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 12 +sidebarPos: 10 --- # Repeaters diff --git a/docs/src/pages/guide/module-features/singular.md b/docs/src/pages/guide/module-features/singular.md index 9e4e6998e..c514ef6c6 100644 --- a/docs/src/pages/guide/module-features/singular.md +++ b/docs/src/pages/guide/module-features/singular.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 16 +sidebarPos: 11 --- # Singular diff --git a/docs/src/pages/guide/module-features/slug.md b/docs/src/pages/guide/module-features/slug.md index 60a6402d8..f82cf95b3 100644 --- a/docs/src/pages/guide/module-features/slug.md +++ b/docs/src/pages/guide/module-features/slug.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 13 +sidebarPos: 12 --- # Slug diff --git a/docs/src/pages/guide/module-features/spreadable.md b/docs/src/pages/guide/module-features/spreadable.md index 77e4d8c96..86dbd3e7a 100644 --- a/docs/src/pages/guide/module-features/spreadable.md +++ b/docs/src/pages/guide/module-features/spreadable.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 14 +sidebarPos: 13 --- # Spreadable diff --git a/docs/src/pages/guide/module-features/stateable.md b/docs/src/pages/guide/module-features/stateable.md index 29d1e55e9..8f9a43f77 100644 --- a/docs/src/pages/guide/module-features/stateable.md +++ b/docs/src/pages/guide/module-features/stateable.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 15 +sidebarPos: 14 --- # Stateable diff --git a/docs/src/pages/guide/module-features/translation.md b/docs/src/pages/guide/module-features/translation.md index 465a77f6f..333dd01b9 100644 --- a/docs/src/pages/guide/module-features/translation.md +++ b/docs/src/pages/guide/module-features/translation.md @@ -1,6 +1,6 @@ --- outline: deep -sidebarPos: 18 +sidebarPos: 15 --- # Translation diff --git a/docs/src/pages/guide/overview.md b/docs/src/pages/guide/overview.md new file mode 100644 index 000000000..fc560304c --- /dev/null +++ b/docs/src/pages/guide/overview.md @@ -0,0 +1,28 @@ +--- +sidebarPos: 1 +sidebarTitle: Guide Overview +--- + +# Guide + +This section covers UI components, forms, and tables used in admin panel of Modularous. + +## Components + +| Page | Description | +|------|-------------| +| [Data Tables](/guide/components/data-tables) | Table component, table options, customization | +| [Forms](/guide/form-inputs/overview) | Form architecture, FormBase, schema flow | +| [Input Form Groups](/guide/form-inputs/input-form-groups) | Form groups and layout | +| [Input Checklist Group](/guide/form-inputs/input-checklist-group) | Checklist group input | +| [Input Comparison Table](/guide/form-inputs/input-comparison-table) | Comparison table input | +| [Input Filepond](/guide/form-inputs/input-filepond) | Filepond file upload | +| [Input Radio Group](/guide/form-inputs/input-radio-group) | Radio group input | +| [Input Select Scroll](/guide/form-inputs/input-select-scroll) | Scrollable select input | +| [Tab Groups](/guide/components/tab-groups) | Tab groups for forms | +| [Tabs](/guide/components/tabs) | Tab component | +| [Stepper Form](/guide/components/stepper-form) | Stepper form component | + +## Architecture Reference + +For system internals (Hydrates, Repositories, schema flow), see [System Reference](/system-reference/overview). diff --git a/docs/src/pages/guide/recipes/crud-module.md b/docs/src/pages/guide/recipes/crud-module.md new file mode 100644 index 000000000..8e21378cc --- /dev/null +++ b/docs/src/pages/guide/recipes/crud-module.md @@ -0,0 +1,136 @@ +--- +sidebarPos: 2 +sidebarTitle: CRUD Module +outline: deep +--- + +# Recipe — CRUD Module + +**Goal**: Ship a working admin CRUD (list, create, edit, delete) for a new entity, wired up to permissions and the module sidebar. + +**Time**: ~10 minutes. + +## Prerequisites + +- You have Modularous installed and can run `php artisan modularity:list`. +- You have a module in mind — we'll use **`Billing`** and an entity called **`Invoice`** for this recipe. + +## 1. Scaffold the module + +```bash +php artisan modularity:make:module Billing invoices \ + --fields="number:string,total:decimal,issued_at:datetime" \ + --traits="HasSlug" \ + --test +``` + +This creates: + +- `modules/Billing/Entities/Invoice.php` — model with the listed fields + `HasSlug` trait +- `modules/Billing/Repositories/InvoiceRepository.php` — repository +- `modules/Billing/Http/Controllers/InvoiceController.php` — CRUD controller +- `modules/Billing/Http/Requests/*` — store / update form requests +- `modules/Billing/Database/Migrations/*_create_invoices_table.php` — migration +- `modules/Billing/Hydrates/Inputs/InvoiceHydrate.php` — form schema hydrate +- Route entries in `routes/admin.php` (or the module's route file) + +See [make:module](/guide/console/generators/make-module) for every flag. + +## 2. Run the migration + +```bash +php artisan modularity:migrate +``` + +## 3. Register the sidebar entry + +In the module's config (typically `modules/Billing/Config/billing.php`): + +```php +'sidebar' => [ + 'invoices' => [ + 'title' => 'Invoices', + 'icon' => 'receipt_long', + 'route' => 'admin.billing.invoices.index', + 'allowedRoles' => ['admin', 'accounting'], + ], +], +``` + +Role filtering uses the [Allowable](/guide/generics/allowable) generic. + +## 4. Create permissions + +```bash +php artisan modularity:create:route:permissions Billing invoices +``` + +This generates Spatie permission records for `index / create / update / destroy` and attaches them to the default admin role. + +See [create:route:permissions](/guide/console/generators/create-route-permissions). + +## 5. Add custom fields to the form + +Open `modules/Billing/Hydrates/Inputs/InvoiceHydrate.php` and adjust the schema returned by `getInputs()`. Typical additions: + +```php +public function getInputs(): array +{ + return [ + ['type' => 'text', 'name' => 'number', 'label' => 'Invoice #'], + ['type' => 'price', 'name' => 'total', 'label' => 'Total'], + ['type' => 'date', 'name' => 'issued_at', 'label' => 'Issued'], + ['type' => 'textarea', 'name' => 'notes'], + ]; +} +``` + +See [Hydrates](/system-reference/hydrates) for the schema contract and [Form Inputs](/guide/form-inputs/overview) for every input type. + +## 6. Warm caches + +```bash +php artisan modularity:cache:warm Billing +``` + +## 7. Verify + +1. Log in to the admin panel as an admin user. +2. Click **Invoices** in the sidebar — you should see an empty data table. +3. Click **Create** — the form should render the fields from step 5. +4. Save — the record appears in the table; edit/delete actions work. + +## Common Variations + +### Add a relationship + +```bash +php artisan modularity:make:route Billing invoices \ + --relationships="Customer:belongsTo" +``` + +See [Relationships](/guide/generics/relationships) for belongsToMany pivots. + +### Add file attachments + +Add `HasFileponds` to the model and an input: + +```php +// Invoice.php +use HasFileponds; + +// InvoiceHydrate.php +['type' => 'filepond', 'name' => 'attachments', 'max' => 5] +``` + +See [File Uploads recipe](./file-uploads) for full walkthrough. + +### Add a state workflow + +See [State Machine recipe](./state-machine). + +## Next Steps + +- [Module Features](/guide/module-features/overview) — stack traits for richer behaviour +- [Data Tables](/guide/components/data-tables) — customise the list view +- [Repositories](/system-reference/repositories) — lifecycle hooks (`hydrate`, `afterSave`) diff --git a/docs/src/pages/guide/recipes/custom-input.md b/docs/src/pages/guide/recipes/custom-input.md new file mode 100644 index 000000000..4ded25a84 --- /dev/null +++ b/docs/src/pages/guide/recipes/custom-input.md @@ -0,0 +1,185 @@ +--- +sidebarPos: 3 +sidebarTitle: Custom Form Input +outline: deep +--- + +# Recipe — Custom Form Input + +**Goal**: Add a new input type that plugs into the schema-driven form system, so route configs can declare `'type' => 'my-input'` and get your component rendered. + +**Time**: ~10 minutes. + +## The Three Layers + +A custom input needs three artefacts: + +| Layer | What | Generated by | +|-------|------|------| +| **Vue component** | The actual input UI | `modularity:create:vue:input` | +| **Hydrate** | Transforms schema config into component props | `modularity:create:input:hydrate` | +| **Registry entry** | Tells the form system `'my-input' → VMyInput` | `registerInputType()` | + +## 1. Scaffold the Vue component + +```bash +php artisan modularity:create:vue:input ColorPicker +``` + +This creates `vue/src/js/components/inputs/VInputColorPicker.vue` with: + +- Vue 3 Composition API setup +- `useInput(props, emit)` wired in +- `useModelValue(props, emit)` for `v-model` binding +- Baseline `v-text-field` fallback — replace with your real UI + +Edit the component: + +```vue +<script setup> +import { useInput, useModelValue } from '@/hooks' + +const props = defineProps({ + modelValue: { type: String, default: null }, + schema: { type: Object, required: true }, +}) + +const emit = defineEmits(['update:modelValue']) + +const { boundProps } = useInput(props, emit) +const { model } = useModelValue(props, emit) +</script> + +<template> + <v-text-field v-model="model" v-bind="boundProps" type="color" /> +</template> +``` + +See [useInput](/system-reference/frontend/composables/use-input) and [useModelValue](/system-reference/frontend/composables/use-model-value) for the props they inject. + +## 2. Scaffold the hydrate + +```bash +php artisan modularity:create:input:hydrate ColorPicker Billing +``` + +This creates `modules/Billing/Hydrates/Inputs/ColorPickerHydrate.php`: + +```php +namespace Modules\Billing\Hydrates\Inputs; + +use Unusualify\Modularity\Hydrates\AbstractHydrate; + +class ColorPickerHydrate extends AbstractHydrate +{ + public const HYDRATE_TYPE = 'color-picker'; + + public function getOutputType(): string + { + return 'color-picker'; + } + + public function hydrate(array $schema): array + { + return array_merge($schema, [ + 'type' => $this->getOutputType(), + 'placeholder' => $schema['placeholder'] ?? '#000000', + // add any computed/derived props here + ]); + } +} +``` + +See [Hydrates](/system-reference/hydrates) for the hydrate contract. + +## 3. Register the input type + +In your app bootstrap (for example `resources/js/app.js` or a module's JS entrypoint): + +```js +import { registerInputType } from '@/components/inputs/registry' +import VInputColorPicker from '@/components/inputs/VInputColorPicker.vue' + +registerInputType('color-picker', 'VInputColorPicker') +// Also register the component globally or locally import where used +``` + +The registry maps schema `type` values to component names. See [Input Registry](/system-reference/frontend/overview#input-registry). + +## 4. Use it in a route config + +```php +[ + 'type' => 'color-picker', + 'name' => 'brand_color', + 'label' => 'Brand color', +] +``` + +The form system will: + +1. Run it through `ColorPickerHydrate::hydrate()` +2. Output schema with `type: 'color-picker'` +3. Map `color-picker` → `VInputColorPicker` via the registry +4. Render with `v-bind` to the component + +## 5. Verify + +1. Open the form. +2. The colour-picker input renders in place of a plain text field. +3. Pick a colour — it propagates through `v-model` to the form state. +4. Submit — the hex string persists to the model. + +## Adding Validation + +Validation rules live in the schema — your hydrate can push them: + +```php +public function hydrate(array $schema): array +{ + return array_merge($schema, [ + 'type' => $this->getOutputType(), + 'rules' => array_merge($schema['rules'] ?? [], [ + 'hex-color', // your rule + ]), + ]); +} +``` + +Register custom rules with `useValidation` — see [useValidation](/system-reference/frontend/composables/use-validation). + +## Adding a Props Factory + +If other components want to reuse your input's props, expose a `makeColorPickerProps` factory: + +```js +import { propsFactory } from 'vuetify/util' + +export const makeColorPickerProps = propsFactory({ + format: { type: String, default: 'hex' }, + showAlpha: Boolean, +}, 'colorPicker') +``` + +## Shipping as Part of a Module + +If the input belongs to a specific module: + +- Component: `modules/Billing/Resources/assets/js/components/VInputColorPicker.vue` +- Hydrate: `modules/Billing/Hydrates/Inputs/ColorPickerHydrate.php` +- Register in the module's JS entrypoint so it loads only when the module is active + +## Common Pitfalls + +| Symptom | Fix | +|---------|-----| +| Input renders as plain text field | Registry mapping missing — confirm `registerInputType()` runs before forms render | +| Schema changes aren't reflected | Clear the hydrate cache: `php artisan modularity:cache:clear` | +| `modelValue` doesn't update on submit | Emit `update:modelValue` (use `useModelValue`, don't mutate prop) | +| Props missing in component | Hydrate isn't merging them — check `hydrate()` return value | + +## Next Steps + +- [Hydrates](/system-reference/hydrates) — full schema contract +- [Form Inputs](/guide/form-inputs/overview) — browse existing inputs for naming / prop conventions +- [Vue Hooks](/system-reference/frontend/composables/overview) — every hook your input can consume diff --git a/docs/src/pages/guide/recipes/file-uploads.md b/docs/src/pages/guide/recipes/file-uploads.md new file mode 100644 index 000000000..2fb4877c7 --- /dev/null +++ b/docs/src/pages/guide/recipes/file-uploads.md @@ -0,0 +1,165 @@ +--- +sidebarPos: 4 +sidebarTitle: File Uploads +outline: deep +--- + +# Recipe — File Uploads + +**Goal**: Let users upload files on a form and retrieve them on the model — with proper temp-file handling, max-count limits, and type filtering. + +**Time**: ~5 minutes. + +## Pick the Right Mechanism + +Modularous offers three file options — choose based on your use case: + +| Mechanism | When | Complexity | +|-----------|------|-----------| +| **Filepond** (`HasFileponds`) | Direct 1:N uploads (attachments, avatars) | Simplest | +| **Files** (`HasFiles`) | Shared file library reused across records | Medium | +| **Media** (`HasImages`) | Images with cropping / role / locale variants | Richest | + +This recipe covers **Filepond**, which is right for 90% of cases. For the others, see [Files and Media](/guide/module-features/files-and-media). + +## 1. Add the trait to your model + +```php +use Unusualify\Modularity\Entities\Traits\HasFileponds; + +class Ticket extends Model +{ + use HasFileponds; +} +``` + +This installs a `morphMany(Filepond::class, 'filepondable')` relation and the accessors `fileponds()`, `getFileponds()`, `hasFilepond()`. + +## 2. Add the trait to your repository + +```php +use Unusualify\Modularity\Repositories\Traits\FilepondsTrait; + +class TicketRepository extends Repository +{ + use FilepondsTrait; +} +``` + +`FilepondsTrait` handles moving files from the temporary uploads table to permanent storage on save, and reverting on rollback. + +## 3. Declare the input in your hydrate + +```php +public function getInputs(): array +{ + return [ + ['type' => 'text', 'name' => 'title'], + [ + 'type' => 'filepond', + 'name' => 'attachments', + 'max' => 5, + 'maxFileSize' => '10MB', + 'acceptedExtensions' => ['pdf', 'doc', 'docx', 'png', 'jpg'], + ], + ]; +} +``` + +See [input-filepond](/guide/form-inputs/input-filepond) for every option. + +## 4. Use the files at runtime + +### Listing + +```php +$ticket = Ticket::find(1); + +// All fileponds regardless of role +$ticket->fileponds; + +// Fileponds scoped to a named role +$ticket->getFileponds('attachments'); + +// Boolean +if ($ticket->hasFilepond('attachments')) { + // ... +} +``` + +### In Blade / email templates + +```blade +@foreach ($ticket->getFileponds('attachments') as $file) + <a href="{{ $file->url }}" download>{{ $file->name }}</a> +@endforeach +``` + +### In Vue / API responses + +Files are automatically serialized when the model is appended with `fileponds`: + +```php +// In your resource or repository +$invoice->append(['fileponds']); +``` + +## 5. Verify + +1. Open the create form — you should see a drag-and-drop Filepond input. +2. Drop a file — a temporary row appears in `temporary_fileponds`. +3. Submit the form — the row moves to `fileponds` and the temp row is deleted. +4. Re-open the record — the file shows up attached. + +## Variations + +### Single-file avatar upload + +```php +[ + 'type' => 'filepond', + 'name' => 'avatar', + 'max' => 1, + 'acceptedExtensions' => ['png', 'jpg', 'jpeg', 'webp'], +] +``` + +Or use the avatar preset input `input-filepond-avatar` for the circular crop UI. See [input-filepond-avatar](/guide/form-inputs/input-filepond-avatar). + +### Document-only with strict size limit + +```php +[ + 'type' => 'filepond', + 'name' => 'documents', + 'max' => 10, + 'maxFileSize' => '5MB', + 'acceptedExtensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx'], +] +``` + +### Cropable images with roles + +Use `HasImages` + `input-image` instead. See [Files and Media — Media / Images](/guide/module-features/files-and-media#media-images). + +## Housekeeping + +Temporary Filepond rows that never get saved accumulate over time. Schedule the cleanup command: + +```bash +php artisan modularity:flush:filepond +``` + +Or add it to `app/Console/Kernel.php`: + +```php +$schedule->command('modularity:flush:filepond')->daily(); +``` + +See [flush:filepond](/guide/console/flush/flush-filepond). + +## Next Steps + +- [File Storage with Filepond](/guide/generics/file-storage-with-filepond) — storage mechanics and database layout +- [Files and Media](/guide/module-features/files-and-media) — the full triple pattern +- [Uploader component](/guide/components/uploader) — the Vue upload widget diff --git a/docs/src/pages/guide/recipes/overview.md b/docs/src/pages/guide/recipes/overview.md new file mode 100644 index 000000000..e3b564b21 --- /dev/null +++ b/docs/src/pages/guide/recipes/overview.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Recipes +--- + +# Recipes & Common Patterns + +End-to-end walkthroughs for the tasks developers do most often on Modularous. Each recipe is self-contained and cross-references the reference docs rather than duplicating them. + +Unlike [Module Features](/guide/module-features/overview) (which describe **what** a trait does) or [Generics](/guide/generics/overview) (which describe **how** a helper works), recipes describe **how to accomplish a goal** — the sequence of commands, files, and config changes from zero to working. + +## Available Recipes + +| Recipe | Goal | Key concepts | +|--------|------|-------------| +| [CRUD Module](./crud-module) | Ship a complete CRUD module with list, create, edit, delete | `make:module`, Repository, Hydrate | +| [File Uploads](./file-uploads) | Let users upload and retrieve files (avatar, attachments) | `HasFileponds`, `input-filepond`, Media Library | +| [State Machine Workflow](./state-machine) | Model records that move through named states with history | `HasStateable`, `Processable`, State events | +| [Custom Form Input](./custom-input) | Add a new input type that plugs into the form schema | Hydrate, `registerInputType`, Vue input component | + +## When to Read a Recipe vs Reference + +- **Recipe** — you know what you want to *build* but not the exact sequence of steps. +- **Reference** — you already know the pattern and need method signatures, options, or edge cases. + +## Recipe Structure + +Every recipe follows the same outline so you can skim: + +1. **Goal** — what you'll have at the end +2. **Prerequisites** — assumed knowledge / required setup +3. **Steps** — numbered, copy-pasteable +4. **Verification** — how to confirm it works +5. **Next steps** — where to go for customisation + +## Contributing a Recipe + +Recipes are most useful when they come from real tasks. If you solve a non-trivial problem with Modularous that required stitching several docs together, consider contributing it here. Keep it: + +- **Goal-oriented** — titled with the outcome, not the mechanism +- **Minimal** — the shortest path that actually works +- **Cross-referenced** — link to the reference docs instead of explaining what they already cover diff --git a/docs/src/pages/guide/recipes/state-machine.md b/docs/src/pages/guide/recipes/state-machine.md new file mode 100644 index 000000000..34535390c --- /dev/null +++ b/docs/src/pages/guide/recipes/state-machine.md @@ -0,0 +1,208 @@ +--- +sidebarPos: 5 +sidebarTitle: State Machine Workflow +outline: deep +--- + +# Recipe — State Machine Workflow + +**Goal**: Model a record that moves through named states (e.g. `draft → review → approved → published`) with transition history, authorization, and a dedicated UI input. + +**Time**: ~15 minutes. + +## When to Use Which Trait + +Modularous has two related traits — pick based on what you need: + +| Trait | Use for | +|-------|---------| +| `HasStateable` | Simple named states on a record (draft / active / archived) — one row in `states` table | +| `Processable` | Full process lifecycle with history and status transitions per step | + +This recipe covers **both** — start with `HasStateable` and escalate to `Processable` when you need process history. + +## 1. Add `HasStateable` to your model + +```php +use Unusualify\Modularity\Entities\Traits\HasStateable; + +class Article extends Model +{ + use HasStateable; +} +``` + +This installs: + +- `morphOne(State::class, 'stateable')` relation +- `stateable()`, `stateable_status` accessors +- `stateableChanged()`, `currentStateableState()`, `previousStateableState()` helpers + +## 2. Add the repository trait + +```php +use Unusualify\Modularity\Repositories\Traits\StateableTrait; + +class ArticleRepository extends Repository +{ + use StateableTrait; +} +``` + +## 3. Configure the state list + +Define the states in your repository (or a dedicated config): + +```php +// ArticleRepository.php +public function getStateableList(): array +{ + return [ + ['value' => 'draft', 'label' => 'Draft'], + ['value' => 'review', 'label' => 'In Review'], + ['value' => 'approved', 'label' => 'Approved'], + ['value' => 'published', 'label' => 'Published'], + ]; +} +``` + +## 4. Add the input to your hydrate + +```php +public function getInputs(): array +{ + return [ + ['type' => 'text', 'name' => 'title'], + ['type' => 'textarea', 'name' => 'body'], + ['type' => 'stateable', 'name' => 'stateable_id'], + ]; +} +``` + +The `stateable` hydrate auto-pulls `items` from `getStateableList()` and emits a `select` input. + +## 5. Query by state + +```php +Article::whereHas('stateable', fn($q) => $q->where('status', 'published'))->get(); + +// Current state +$article->currentStateableState(); // 'published' +$article->stateable_status; // accessor +``` + +## 6. Listen for state transitions + +Create a broadcast event that fires on state changes: + +```bash +php artisan modularity:make:event StateableUpdated Blog +``` + +Then in the event (already extends `ModelEvent`): + +```php +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Broadcasting\InteractsWithBroadcasting; +use Unusualify\Modularity\Events\ModelEvent; + +class StateableUpdated extends ModelEvent implements ShouldBroadcast +{ + use InteractsWithBroadcasting; + + public function broadcastWhen(): bool + { + return $this->hasStateable && $this->stateableChanged; + } +} +``` + +`$hasStateable`, `$stateableChanged`, `$previousStateableState`, `$currentStateableState` are auto-populated by the [`EventStateable`](/system-reference/backend/events/traits/event-stateable) trait in `ModelEvent`. + +Dispatch it from your repository's `afterSave()`: + +```php +public function afterSave($object, $fields) +{ + parent::afterSave($object, $fields); + + if ($object->stateableChanged()) { + event(new StateableUpdated($object)); + } +} +``` + +## 7. Subscribe on the frontend + +Use `BroadcastManager` to share channel config and subscribe with Echo: + +```php +// Controller +$broadcastConfig = BroadcastManager::forModel($article, [ + StateableUpdated::class, +]); + +return inertia('Article/Show', compact('article', 'broadcastConfig')); +``` + +```js +// Vue +import { onMounted, onBeforeUnmount } from 'vue' +import { usePage } from '@inertiajs/vue3' + +const { broadcastConfig } = usePage().props + +onMounted(() => { + broadcastConfig.forEach(({ name, type, events }) => { + const channel = type === 'private' ? Echo.private(name) : Echo.channel(name) + events.forEach(({ event }) => { + channel.listen(`.${event}`, ({ currentStateableState }) => { + // update your Vue state here + }) + }) + }) +}) +``` + +See [Broadcasting](/guide/broadcasting/overview) for the full flow. + +## 8. Verify + +1. Create an Article — state defaults to `draft`. +2. Edit and switch state to `review` — the broadcast event fires. +3. `$article->previousStateableState()` returns `draft`; `currentStateableState()` returns `review`. + +## Escalating to `Processable` + +Use `Processable` when you need: + +- **Per-step history** (`process_histories` table) +- **Assignment per step** (combined with `Assignable`) +- **Conditional transitions** with explicit `advance()` / `rollback()` calls + +```php +use Unusualify\Modularity\Entities\Traits\Processable; + +class Claim extends Model +{ + use Processable; +} +``` + +Then expose the `process` input: + +```php +[ + 'type' => 'process', + 'name' => 'process_id', +] +``` + +See [Processable](/guide/module-features/processable) for the full pattern. + +## Next Steps + +- [HasStateable](/guide/module-features/stateable) — entity trait reference +- [Processable](/guide/module-features/processable) — richer state machine with history +- [Events & Broadcasting](/guide/broadcasting/overview) — real-time state updates +- [ModelEvent / EventStateable](/system-reference/backend/events/traits/event-stateable) — auto-captured state context diff --git a/docs/src/pages/index.md b/docs/src/pages/index.md index daa8528d6..592e3e672 100644 --- a/docs/src/pages/index.md +++ b/docs/src/pages/index.md @@ -3,13 +3,13 @@ layout: home hero: - name: "Unusualify Modularity" + name: "Unusualify Modularous" # text: "Unusualify Modularity" tagline: Laravel & Vue.js - Vuetify.js Powered Laravel Project Generator actions: - theme: brand - text: What is Modularity? - link: get-started/what-is-modularity + text: What is Modularous? + link: get-started/what-is-modularous - theme: alt text: Get Started link: get-started/installation-guide @@ -18,13 +18,13 @@ hero: link: guide/components/data-tables - theme: alt text: GitHub - link: https://www.github.com/unusualify/modularity + link: https://www.github.com/unusualify/modularous features: - title: Fast Backend Development details: Develop Your Backend Application with Simple Commands - title: Admin Panel - details: While you consturct your backend application, let administration panel construct itself + details: While you construct your backend application, let administration panel construct itself - title: Easy Customization details: User interface, form and tables can be customized over config files - title: Vue & Laravel diff --git a/docs/src/pages/system-reference/api.md b/docs/src/pages/system-reference/api.md index fa8b4b901..8fad3974e 100644 --- a/docs/src/pages/system-reference/api.md +++ b/docs/src/pages/system-reference/api.md @@ -50,7 +50,7 @@ See [Hydrates](./hydrates#adding-a-new-input) for full flow (PHP Hydrate + Vue c ## Route Generation -Use `php artisan modularity:make:route` to scaffold routes, migrations, controllers, repositories from module config. See [make:route](/guide/commands/Generators/make-route). +Use `php artisan modularity:make:route` to scaffold routes, migrations, controllers, repositories from module config. See [make:route](/guide/console/generators/make-route). ## Currency Provider diff --git a/docs/src/pages/system-reference/architecture.md b/docs/src/pages/system-reference/architecture.md index a94451c1c..8b7bf9504 100644 --- a/docs/src/pages/system-reference/architecture.md +++ b/docs/src/pages/system-reference/architecture.md @@ -5,7 +5,7 @@ sidebarTitle: Architecture # Architecture -Modularity is a modular Laravel admin package with Vue/Vuetify and Inertia. It uses the Repository pattern, config-driven forms/tables, and a Hydrate system to transform module config into frontend schema. +Modularous is a modular Laravel admin package with Vue/Vuetify and Inertia. It uses the Repository pattern, config-driven forms/tables, and a Hydrate system to transform module config into frontend schema. ## Directory Structure @@ -96,7 +96,7 @@ flowchart LR ``` LaravelServiceProvider (publish config, assets, views) ↓ -BaseServiceProvider (register Modularity, bindings, commands, migrations) +BaseServiceProvider (register Modularous, bindings, commands, migrations) ↓ RouteServiceProvider (map system routes, module routes, auth routes) ``` diff --git a/docs/src/pages/system-reference/backend.md b/docs/src/pages/system-reference/backend.md deleted file mode 100644 index 6763fe830..000000000 --- a/docs/src/pages/system-reference/backend.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -sidebarPos: 5 -sidebarTitle: Backend ---- - -# Backend - -## Controllers - -**Hierarchy**: CoreController → PanelController → BaseController - -| Layer | Purpose | -|-------|---------| -| **CoreController** | Base HTTP controller | -| **PanelController** | Route/model resolution, index options, authorization, `$this->repository` | -| **BaseController** | View prefix, form schema, index/create/edit flow, `setupFormSchema()` | - -**Key traits** (BaseController): ManageIndexAjax, ManageInertia, ManagePrevious, ManageSingleton, ManageTranslations - -**Flow**: `preload()` → `addWiths()`, `setupFormSchema()` → `index()` / `create()` / `edit()` → `respondToIndexAjax()` for AJAX - -## Console Commands - -Discovered via `CommandDiscovery::discover()` in BaseServiceProvider. - -| Category | Path | Examples | -|----------|------|----------| -| Make | Console/Make/ | make:model, make:controller, make:route, make:repository | -| Cache | Console/Cache/ | cache:clear, cache:warm, cache:list | -| Migration | Console/Migration/ | migrate, migrate:refresh, migrate:rollback | -| Module | Console/Module/ | route:enable, route:disable, route:status | -| Roles | Console/Roles/ | roles:load, roles:refresh, roles:list | -| Setup | Console/Setup/ | install, create-superadmin | -| Seed | Console/Seed/ | seed:payment, seed:pricing | -| Build | Console/ | build, refresh | - -**Key commands**: -- `modularity:build` — rebuild Vue assets -- `modularity:route:enable` / `modularity:route:disable` — toggle routes -- `modularity:route:status` — list route status per module - -## Entities - -**Base**: `Model`, `Singleton` - -**Core models**: User, UserOauth, Profile, Company, Setting, Tag, Tagged, Media, File, Filepond, Block, Repeater, RelatedItem, Revision, Process, ProcessHistory, Chat, ChatMessage, Assignment, Authorization, CreatorRecord, Feature, State, Stateable, Spread - -**Entity traits** (examples): HasImages, HasFiles, HasFileponds, HasSlug, HasStateable, HasPriceable, HasPayment, HasPosition, HasCreator, HasRepeaters, HasProcesses, HasTranslation, IsTranslatable, Assignable, Chatable, Processable - -**Enums**: Permission, UserRole, RoleTeam, ProcessStatus, PaymentStatus, AssignmentStatus - -## Services - -| Service | Purpose | -|---------|---------| -| Connector | Connector service | -| MigrationBackup | Migration backup | -| Currency/SystemPricingCurrencyProvider | Currency from system pricing | -| Currency/NullCurrencyProvider | No-op when no pricing module | -| Roles/AbstractRolesLoader | Base roles loader | -| Roles/CmsRolesLoader, CrmRolesLoader, ErpRolesLoader | Role definitions | -| FilepondManager | Filepond uploads | -| ModularityCacheService | Cache management | - -## Support - -| Class | Purpose | -|-------|---------| -| **Finder** | Resolve model/repository/controller from route name or table | -| **RouteGenerator** | Scaffold routes, migrations, controllers, repositories from module config | -| **CommandDiscovery** | Discover commands from glob paths | -| **FileLoader** | Translation file loader | diff --git a/docs/src/pages/system-reference/backend/activators/modularity-activator.md b/docs/src/pages/system-reference/backend/activators/modularity-activator.md new file mode 100644 index 000000000..af70aa8d7 --- /dev/null +++ b/docs/src/pages/system-reference/backend/activators/modularity-activator.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 2 +sidebarTitle: ModularityActivator +--- + +# ModularityActivator + +**File**: `src/Activators/ModularityActivator.php` +**Namespace**: `Unusualify\Modularity\Activators` +**Implements**: `Nwidart\Modules\Contracts\ActivatorInterface` + +`ModularityActivator` is the module-level status manager. It reads/writes module activation flags (by module name), persists them to a statuses file, and caches the resolved map. + +## Configuration Keys + +Read from `modules.activators.modularity.*`: + +- `statuses-file` -> JSON file path for module statuses +- `cache-key` -> cache key for statuses map +- `cache-lifetime` -> cache lifetime for statuses map + +Cache driver comes from `modules.cache.driver`; cache enable toggle is `modules.cache.enabled`. + +## Core Responsibilities + +| Method | Purpose | +|--------|---------| +| `enable(Module $module)` / `disable(Module $module)` | Mark module active/inactive by module name | +| `setActiveByName(string $name, bool $status)` | Persist one module status | +| `hasStatus(Module $module, bool $status)` | Check module status with default-false semantics | +| `getModulesStatuses()` | Resolve statuses from cache or JSON | +| `reset()` | Clear statuses file and cache | +| `delete(Module $module)` | Remove explicit status record for a module | + +## Storage Format + +The statuses file stores an object keyed by module name: + +```json +{ + "Blog": true, + "Shop": false +} +``` + +## Notes + +- If `modules.cache.enabled` is `false`, reads are done directly from JSON file each time. +- Cache is always flushed after writes (`setActiveByName`, `delete`, `reset`). + +## Related + +- [ModuleActivator](./module-activator) — route-level status manager +- [Module System](/system-reference/modules) diff --git a/docs/src/pages/system-reference/backend/activators/module-activator.md b/docs/src/pages/system-reference/backend/activators/module-activator.md new file mode 100644 index 000000000..1110bb8b1 --- /dev/null +++ b/docs/src/pages/system-reference/backend/activators/module-activator.md @@ -0,0 +1,59 @@ +--- +sidebarPos: 3 +sidebarTitle: ModuleActivator +--- + +# ModuleActivator + +**File**: `src/Activators/ModuleActivator.php` +**Namespace**: `Unusualify\Modularity\Activators` +**Extends**: `Nwidart\Modules\Activators\FileActivator` + +`ModuleActivator` is the route-level status manager used by Modularous to enable or disable specific route actions (for example `create`, `edit`, `destroy`) inside a module. + +## Constructor Inputs + +| Parameter | Purpose | +|-----------|---------| +| `Container $app` | Resolves cache/files/config services | +| `string $cacheKey` | Cache key used for route status map | +| `string $statusesFile` | Per-module JSON file path (`routes_statuses.json`) | + +The class uses a fixed cache lifetime of `604800` seconds (7 days). + +## Core Responsibilities + +| Method | Purpose | +|--------|---------| +| `enable($route)` / `disable($route)` | Toggle one route status | +| `setActiveByName(string $name, bool $status)` | Persist one route status | +| `hasStatus($route, bool $status)` | Check route status with default-false semantics | +| `getRoutesStatuses()` | Resolve statuses from cache or JSON | +| `delete($route)` | Remove explicit status record for one route | +| `reset()` | Delete statuses file, clear map and cache | +| `getRoutes()` | Return all stored route keys | + +## Storage Format + +The route statuses file stores an object keyed by route/action key: + +```json +{ + "index": true, + "create": false, + "destroy": true +} +``` + +## Notes + +- If `modules.cache.enabled` is `false`, statuses are read directly from file. +- Every write operation updates JSON and flushes cache. +- Unset routes are treated as `false` in `hasStatus(...)`. + +## Related + +- [ModularityActivator](./modularity-activator) — module-level status manager +- [Module System](/system-reference/modules) +- [Commands · route:enable](/guide/console/module/route-enable) +- [Commands · route:disable](/guide/console/module/route-disable) diff --git a/docs/src/pages/system-reference/backend/activators/overview.md b/docs/src/pages/system-reference/backend/activators/overview.md new file mode 100644 index 000000000..e5afcf5ef --- /dev/null +++ b/docs/src/pages/system-reference/backend/activators/overview.md @@ -0,0 +1,36 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Activators +--- + +# Activators + +**Directory**: `src/Activators/` +**Namespace**: `Unusualify\Modularity\Activators` + +Activators persist and resolve enable/disable state. Modularous uses two activator layers: + +- **Module activator** (`ModularityActivator`) controls whether a module is enabled at all. +- **Route activator** (`ModuleActivator`) controls whether specific route actions inside a module are enabled. + +## Classes + +| Class | Purpose | Page | +|-------|---------|------| +| `ModularityActivator` | Stores and resolves module-level statuses (`enabled` / `disabled`) via JSON + cache | [ModularityActivator →](./modularity-activator) | +| `ModuleActivator` | Stores and resolves route-level statuses per module (`routes_statuses.json`) | [ModuleActivator →](./module-activator) | + +## Activation Model + +1. A module is discovered and checked by **module-level** status. +2. If module-level status allows it, routes are checked by **route-level** status. +3. Route enable/disable commands update route statuses without fully disabling the module. + +This makes it possible to keep a module active while disabling selected route actions. + +## Related + +- [Module System](/system-reference/modules) — high-level module lifecycle and route actions +- [Commands · route:enable](/guide/console/module/route-enable) +- [Commands · route:disable](/guide/console/module/route-disable) diff --git a/docs/src/pages/system-reference/backend/brokers/overview.md b/docs/src/pages/system-reference/backend/brokers/overview.md new file mode 100644 index 000000000..c299837de --- /dev/null +++ b/docs/src/pages/system-reference/backend/brokers/overview.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Brokers +--- + +# Brokers + +**Directory**: `src/Brokers/` +**Namespace**: `Unusualify\Modularity\Brokers` + +The broker layer powers Modularous registration verification flow behind the [`Register` facade](/system-reference/backend/facades/register). It mirrors Laravel's password broker design, but adapts it for email-verification-based registration. + +## Classes + +| Class | Role | Page | +|-------|------|------| +| `RegisterBroker` | Executes verification-link and registration-token operations | [RegisterBroker →](./register-broker) | +| `RegisterBrokerManager` | Resolves named broker instances and selects default broker | [RegisterBrokerManager →](./register-broker-manager) | +| `TokenRepositoryInterface` | Contract extension for email-based token repository methods | [TokenRepositoryInterface →](./token-repository-interface) | + +## Flow + +1. Controller/facade calls `Register::broker()` or `Register::sendResetLink(...)`. +2. `RegisterBrokerManager` resolves a `RegisterBroker` for the configured broker name. +3. `RegisterBroker` creates/validates/deletes verification tokens. +4. On successful validation, registration callback is executed. + +## Related + +- [Facades · Register](/system-reference/backend/facades/register) +- [HTTP · Auth · PreRegisterController](/system-reference/backend/http/controllers/auth/pre-register-controller) +- [HTTP · Auth · CompleteRegisterController](/system-reference/backend/http/controllers/auth/complete-register-controller) diff --git a/docs/src/pages/system-reference/backend/brokers/register-broker-manager.md b/docs/src/pages/system-reference/backend/brokers/register-broker-manager.md new file mode 100644 index 000000000..85a7d24ea --- /dev/null +++ b/docs/src/pages/system-reference/backend/brokers/register-broker-manager.md @@ -0,0 +1,40 @@ +--- +sidebarPos: 3 +sidebarTitle: RegisterBrokerManager +--- + +# RegisterBrokerManager + +**File**: `src/Brokers/RegisterBrokerManager.php` +**Namespace**: `Unusualify\Modularity\Brokers` +**Extends**: `Illuminate\Auth\Passwords\PasswordBrokerManager` + +`RegisterBrokerManager` is the broker factory behind the `auth.register` binding. It resolves named registration brokers and builds `RegisterBroker` instances with the configured provider/connection/token settings. + +## Key Behavior + +| Method | Behavior | +|--------|----------| +| `resolve($name)` | Reads broker config, throws `InvalidArgumentException` if missing, returns `RegisterBroker` | +| `getDefaultDriver()` | Returns `register_verified_users` as the default broker name | + +## Resolution Details + +When resolving a broker, the manager composes: + +- token repository from password broker config +- user provider via `auth.createUserProvider(...)` +- DB connection from broker config (`connection`) +- full config array passed into `RegisterBroker` + +## Failure Mode + +If requested broker name is undefined, `resolve($name)` throws: + +`InvalidArgumentException("Email verification broker [{$name}] is not defined.")` + +## Related + +- [RegisterBroker](./register-broker) +- [Facades · Register](/system-reference/backend/facades/register) +- [Providers · BaseServiceProvider](/system-reference/backend/providers/base-service-provider) diff --git a/docs/src/pages/system-reference/backend/brokers/register-broker.md b/docs/src/pages/system-reference/backend/brokers/register-broker.md new file mode 100644 index 000000000..74a701c69 --- /dev/null +++ b/docs/src/pages/system-reference/backend/brokers/register-broker.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 2 +sidebarTitle: RegisterBroker +--- + +# RegisterBroker + +**File**: `src/Brokers/RegisterBroker.php` +**Namespace**: `Unusualify\Modularity\Brokers` +**Extends**: `Illuminate\Auth\Passwords\PasswordBroker` +**Implements**: `Unusualify\Modularity\Contracts\RegisterBroker` + +`RegisterBroker` performs the actual registration verification workflow: sending verification links, validating email+token pairs, and finalizing registration callbacks. + +## Constructor Dependencies + +| Dependency | Purpose | +|------------|---------| +| `TokenRepositoryInterface $tokens` | Create/check throttled tokens | +| `UserProvider $users` | User resolution (inherited behavior) | +| `ConnectionInterface $connection` | Direct DB reads/deletes for token rows | +| `array $config` | Token table/expiry settings | + +## Core Methods + +| Method | Purpose | +|--------|---------| +| `sendVerificationLink(array $credentials, ?Closure $callback = null)` | Sends verification token for an unregistered email | +| `register(array $credentials, Closure $callback)` | Validates token and executes registration callback | +| `validateRegister(array $credentials)` | Returns `VERIFICATION_SUCCESS` or error status constants | +| `emailIsRegistered($email)` | Checks users table for existing email | +| `emailTokenExists($email, $token)` | Verifies token record presence, expiry, and hash | +| `deleteToken($email)` | Deletes token row after successful register | + +## Return Statuses + +The class returns facade-compatible statuses such as: + +- `ALREADY_REGISTERED` +- `RESET_THROTTLED` +- `INVALID_VERIFICATION_TOKEN` +- `VERIFICATION_SUCCESS` +- `VERIFICATION_LINK_SENT` + +## Notes + +- `sendVerificationLink(...)` creates a temporary `User` with only email to reuse token repository APIs. +- If callback is provided to `sendVerificationLink`, callback output can override default success response. +- Token expiry is computed as `created_at + (config['expire'] * 60 seconds)`. + +## Related + +- [RegisterBrokerManager](./register-broker-manager) +- [Facades · Register](/system-reference/backend/facades/register) diff --git a/docs/src/pages/system-reference/backend/brokers/token-repository-interface.md b/docs/src/pages/system-reference/backend/brokers/token-repository-interface.md new file mode 100644 index 000000000..24a171b24 --- /dev/null +++ b/docs/src/pages/system-reference/backend/brokers/token-repository-interface.md @@ -0,0 +1,26 @@ +--- +sidebarPos: 4 +sidebarTitle: TokenRepositoryInterface +--- + +# TokenRepositoryInterface + +**File**: `src/Brokers/TokenRepositoryInterface.php` +**Namespace**: `Unusualify\Modularity\Brokers` +**Extends**: `Illuminate\Auth\Passwords\TokenRepositoryInterface` + +Modularous token repository contract used by registration broker flow. It extends Laravel's token repository interface with email-oriented method signatures. + +## Added Contract Methods + +| Method | Signature | Purpose | +|--------|-----------|---------| +| `recentlyCreatedToken` | `($email)` | Checks throttle state for an email | +| `create` | `($email)` | Creates token for an email | + +These signatures are used by `RegisterBroker`, which operates on email-centric registration flow instead of only `CanResetPassword` model inputs. + +## Related + +- [RegisterBroker](./register-broker) +- [RegisterBrokerManager](./register-broker-manager) diff --git a/docs/src/pages/system-reference/backend/entities/assignment.md b/docs/src/pages/system-reference/backend/entities/assignment.md new file mode 100644 index 000000000..5df78d468 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/assignment.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 2 +sidebarTitle: Assignment +--- + +# Assignment + +**File**: `src/Entities/Assignment.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` +**Traits**: `SoftDeletes`, `AssignmentScopes`, `HasFileponds` + +Task assignment model with polymorphic relationships to the assignable target, assignee, and assigner. Tracks status via the `AssignmentStatus` enum with due dates, completion timestamps, and file attachments. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `assignable_id` | `int` | Target model ID | +| `assignable_type` | `string` | Target model class | +| `assignee_id` | `int` | Assigned-to user/model ID | +| `assignee_type` | `string` | Assigned-to model class | +| `assigner_id` | `int` | Assigned-by user ID (auto-set from auth) | +| `assigner_type` | `string` | Assigned-by model class (auto-set from auth) | +| `status` | `AssignmentStatus` | Current status (enum cast) | +| `title` | `string` | Assignment title | +| `description` | `string` | Description/instructions | +| `due_at` | `datetime` | Deadline | +| `accepted_at` | `datetime` | When the assignee accepted | +| `completed_at` | `datetime` | When the assignment was completed | + +## Boot Events + +| Event | Action | +|-------|--------| +| `creating` | Auto-fills `assigner_id` and `assigner_type` from the authenticated user | +| `created` | Dispatches `AssignmentCreated` | +| `updated` | Dispatches `AssignmentUpdated` | + +## Relationships + +### `assignable(): MorphTo` + +The target entity this assignment refers to. + +### `assignee(): MorphTo` + +The user or model the task is assigned to. + +### `assigner(): MorphTo` + +The user who created the assignment. + +## Accessors + +| Accessor | Type | Description | +|----------|------|-------------| +| `assignee_name` | `string` | Assignee's name or email | +| `assignee_avatar` | `string` | Assignee's avatar URL | +| `assigner_name` | `string` | Assigner's name or email | +| `status_label` | `string` | Human-readable status | +| `status_color` | `string` | Status colour for the UI | +| `status_icon` | `string` | Status icon identifier | +| `status_icon_color` | `string` | Icon colour | +| `status_interval_description` | `string` | Time-related label (due/completed/updated) with formatted date | +| `status_vuetify_icon` | `string` | Vuetify `<v-icon>` HTML snippet | +| `attachments` | `Collection` | Filepond uploads with role `attachments` | +| `preliminaries` | `Collection` | Filepond uploads with role `preliminaries` | + +## AssignmentStatus Enum + +`PENDING`, `ACCEPTED`, `COMPLETED`, `CANCELLED`, `REJECTED` — each value provides `label()`, `color()`, `icon()`, `iconColor()`, and `timeIntervalDescription()`. + +## Related + +- [Assignable](/system-reference/backend/entity-traits/relationships/assignable) — trait to make a model assignable +- [Filepond](./filepond) — file attachments diff --git a/docs/src/pages/system-reference/backend/entities/authorization.md b/docs/src/pages/system-reference/backend/entities/authorization.md new file mode 100644 index 000000000..52ff28b9b --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/authorization.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 3 +sidebarTitle: Authorization +--- + +# Authorization + +**File**: `src/Entities/Authorization.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Polymorphic pivot model that links an authorized entity (e.g. a user) to an authorizable model. Dispatches notification events when records are created or the authorized party changes. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `authorized_id` | `int` | The entity that has authorization | +| `authorized_type` | `string` | Authorized entity's class | +| `authorizable_id` | `int` | The model being authorized on | +| `authorizable_type` | `string` | Authorizable model's class | + +## Boot Events + +| Event | Action | +|-------|--------| +| `created` | Dispatches `AuthorizableCreated` | +| `updated` | Dispatches `AuthorizableUpdated` (only when `authorized_id` or `authorized_type` changes) | + +## Relationships + +### `authorized(): MorphTo` + +The entity (typically a user) that holds authorization. + +### `authorizable(): MorphTo` + +The model this authorization applies to. + +## Table + +Resolved from `modularity.tables.authorizations`, defaults to `modularity_authorizations`. + +## Related + +- [HasAuthorizable](/system-reference/backend/entity-traits/relationships/has-authorizable) — adds authorization support to models diff --git a/docs/src/pages/system-reference/backend/entities/block.md b/docs/src/pages/system-reference/backend/entities/block.md new file mode 100644 index 000000000..b1ae60b3a --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/block.md @@ -0,0 +1,166 @@ +--- +sidebarPos: 4 +sidebarTitle: Block +--- + +# Block + +**File**: `src/Entities/Block.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` +**Traits**: `HasFiles`, `HasImages`, `HasPresenter`, `HasRelated` + +Eloquent model that backs the block content system. Extends Laravel's base `Model` directly (not the Modularous `Model`) and uses no timestamps. Blocks are attached to a parent model via a morph relation and can be nested (parent/child). Each block stores its content as a JSON array and has a configurable type and editor name. + +## Database Table + +Configurable via `modularityConfig('blocks_table', 'twill_blocks')`. Defaults to `twill_blocks` for compatibility with Twill-based setups. + +### Columns + +| Column | Type | Description | +|--------|------|-------------| +| `blockable_id` | string | ID of the owning model | +| `blockable_type` | string | Class of the owning model | +| `position` | integer | Sort order within the same blockable + editor_name | +| `content` | JSON | All block field values as a keyed object | +| `type` | string | Block type identifier — maps to a Blade view | +| `child_key` | string\|null | Identifies which repeater slot this child occupies | +| `parent_id` | integer\|null | `null` = root block; non-null = nested child of the given block | +| `editor_name` | string\|null | Named editor this block belongs to (`'default'` when null) | + +## Traits + +| Trait | What it adds | +|-------|-------------| +| `HasFiles` | `files()` morph-many to `File` | +| `HasImages` | `medias()` morph-many to `Media` (eager-loaded via `$with`) | +| `HasPresenter` | `$presenter` attribute resolved from config | +| `HasRelated` | `relatedItems()` morph-many to `RelatedItem` | + +## Relationships + +```php +// Owning model (any model using HasBlocks) +public function blockable(): MorphTo + +// Direct child blocks (parent_id = this block's id) +public function children(): HasMany // → Block +``` + +## Scopes + +### `scopeEditor($query, string $name = 'default')` + +Filters blocks by `editor_name`. When `$name` is `'default'`, includes both `editor_name = 'default'` and `editor_name IS NULL`. + +```php +// All blocks in the default editor +$model->blocks()->editor()->get(); + +// All blocks in the sidebar editor +$model->blocks()->editor('sidebar')->get(); +``` + +## Content Access Methods + +These helpers read fields out of the `content` JSON column: + +### `input(string $name): mixed` + +Returns `$this->content[$name]` or `null`. + +```php +$block->input('title'); // "Hello World" +$block->input('missing_key'); // null +``` + +### `translatedInput(string $name, ?string $forceLocale = null): mixed` + +Returns a locale-specific value from a translated field. Falls back to `translatable.fallback_locale` when the current locale is missing and `use_property_fallback` is enabled. + +```php +// content = { "title": { "en": "Hello", "de": "Hallo" } } +$block->translatedInput('title'); // "Hello" (current locale) +$block->translatedInput('title', 'de'); // "Hallo" +``` + +### `checkbox(string $name): bool` + +Returns `true` when the field is a checked checkbox value. + +```php +$block->checkbox('show_border'); // true / false +``` + +### `browserIds(string $name): array` + +Returns the array of IDs stored under `content.browsers.$name`. Used to retrieve browser-type relationship selections. + +```php +$block->browserIds('related_products'); // [4, 7, 12] +``` + +## Rendering + +Blocks are rendered through `HasBlocks` on the owning model — not directly from the `Block` model. + +```php +// Render all default-editor blocks (with child blocks) +echo $page->renderBlocks(); + +// Render a named editor +echo $page->renderNamedBlocks('sidebar'); + +// Render without child blocks +echo $page->renderNamedBlocks('default', renderChilds: false); + +// Custom view mappings +echo $page->renderBlocks(true, [ + 'hero' => 'blocks.custom-hero', +]); + +// Pass extra data to Blade views +echo $page->renderBlocks(true, [], ['theme' => 'dark']); +``` + +### Render pipeline + +For each root block in the named editor, `renderNamedBlocks()`: + +1. Finds the block's view via `BlockConfig::findFirstWithType($block->type)->getBlockView($blockViewMappings)` +2. Calls `$class->getData($data, $block)` to enrich the data array +3. Renders the Blade view with `->with('block', $block)` +4. If `$renderChilds = true`, repeats steps 1–3 for every child and appends their rendered HTML + +### Presenter + +When `modularityConfig('block_editor.block_presenter_path')` is set, the `$presenter` attribute resolves to that class path. This follows the standard Modularous presenter pattern. + +## Parent / Child Nesting + +Blocks support **one level** of parent/child nesting via `parent_id`: + +``` +Block { id: 10, type: 'columns', parent_id: null } ← root +├── Block { id: 11, type: 'column-item', parent_id: 10, child_key: 'items' } +└── Block { id: 12, type: 'column-item', parent_id: 10, child_key: 'items' } +``` + +- Root blocks have `parent_id = null` and are rendered by `renderNamedBlocks`. +- Child blocks are collected via `$this->blocks->where('parent_id', $block->id)` during rendering. +- `child_key` identifies which repeater slot in the parent's schema this child occupies. + +> Nesting is **not recursive** — only one level of parent→child is supported. + +## Configuration + +| Config key | Default | Description | +|------------|---------|-------------| +| `modularity.blocks_table` | `'twill_blocks'` | Database table name | +| `modularity.block_editor.block_presenter_path` | `null` | Presenter class path for block models | + +## Related + +- [HasBlocks](/system-reference/backend/entity-traits/secondary/has-blocks) — adds block support to models +- [Repeater](./repeater) — similar concept for repeatable content diff --git a/docs/src/pages/system-reference/backend/entities/chat-message.md b/docs/src/pages/system-reference/backend/entities/chat-message.md new file mode 100644 index 000000000..a75ca324f --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/chat-message.md @@ -0,0 +1,53 @@ +--- +sidebarPos: 6 +sidebarTitle: ChatMessage +--- + +# ChatMessage + +**File**: `src/Entities/ChatMessage.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` +**Traits**: `HasCreator`, `HasFileponds`, `ChatMessageScopes` + +Individual message within a [Chat](./chat). Tracks read/star/pin/sent/received status and supports file attachments via Filepond. Always eager-loads its `creator`. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `chat_id` | `int` | Parent chat ID | +| `content` | `string` | Message body | +| `is_read` | `bool` | Read status | +| `is_starred` | `bool` | Starred flag | +| `is_pinned` | `bool` | Pinned flag (only one per chat) | +| `is_sent` | `bool` | Sent status | +| `is_received` | `bool` | Received status | +| `edited_at` | `datetime` | When the message was last edited | +| `notified_at` | `datetime` | When a notification was sent | + +## Boot Behaviour + +When a message is updated with `is_pinned = true`, all other messages in the same chat are unpinned — only one message can be pinned at a time. + +## Relationships + +### `chat(): BelongsTo` + +The parent [Chat](./chat) this message belongs to. + +## Accessors + +| Accessor | Type | Description | +|----------|------|-------------| +| `user_profile` | `array\|null` | Profile data of the message creator (via `get_user_profile()`) | +| `attachments` | `Collection` | Filepond uploads with role `attachments` | + +## Table + +Resolved from `modularity.tables.chat_messages`. + +## Related + +- [Chat](./chat) — parent chat room +- [HasCreator](/system-reference/backend/entity-traits/relationships/has-creator) — tracks who sent the message diff --git a/docs/src/pages/system-reference/backend/entities/chat.md b/docs/src/pages/system-reference/backend/entities/chat.md new file mode 100644 index 000000000..a966aedc5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/chat.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 5 +sidebarTitle: Chat +--- + +# Chat + +**File**: `src/Entities/Chat.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` + +Chat room attached to a parent model via a polymorphic relationship. A chat contains ordered messages, supports pinned messages, and exposes file attachments collected from its messages. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `chatable_id` | `int` | Parent model ID | +| `chatable_type` | `string` | Parent model class | + +## Relationships + +### `chatable(): MorphTo` + +The parent model this chat is associated with. + +### `messages(): HasMany` + +All [ChatMessage](./chat-message) records in this chat. + +### `latestMessage(): HasOne` + +The most recent message (using `latestOfMany('created_at')`). + +### `fileponds(): HasManyThrough` + +All Filepond uploads through messages in this chat. + +## Accessors + +| Accessor | Type | Description | +|----------|------|-------------| +| `attachments` | `Collection` | All fileponds with role `attachments`, formatted via `mediableFormat()` | +| `pinned_message` | `ChatMessage\|null` | The currently pinned message | + +## Table + +Resolved from `modularity.tables.chats`. + +## Related + +- [ChatMessage](./chat-message) — individual messages +- [ChatController](/system-reference/backend/http/controllers/chat-controller) — chat API endpoints +- [Chatable](/system-reference/backend/entity-traits/relationships/chatable) — trait that adds chat support to models diff --git a/docs/src/pages/system-reference/backend/entities/company.md b/docs/src/pages/system-reference/backend/entities/company.md new file mode 100644 index 000000000..edbd523f7 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/company.md @@ -0,0 +1,65 @@ +--- +sidebarPos: 7 +sidebarTitle: Company +--- + +# Company + +**File**: `src/Entities/Company.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` +**Traits**: `HasFactory`, `HasSpreadable` + +Organisation/company record. Users belong to a company, which holds billing and address information. Supports the `HasSpreadable` trait for extending fields dynamically via JSON. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `name` | `string` | Company name | +| `address` | `string` | Street address | +| `city` | `string` | City | +| `state` | `string` | State/province | +| `country_id` | `int` | Country reference | +| `zip_code` | `string` | Postal code | +| `phone` | `string` | Phone number | +| `vat_number` | `string` | VAT number | +| `tax_id` | `string` | Tax ID | + +## Relationships + +### `users(): HasMany` + +All users belonging to this company. + +### `country(): BelongsTo` + +The company's country (from `SystemUtility` module). + +### `paymentCountry(): BelongsTo` + +Payment-specific country record (from `SystemPayment` module), resolved via `country_id`. + +## Accessors + +| Accessor | Type | Description | +|----------|------|-------------| +| `country_name` | `string\|null` | Country display name | +| `company_type` | `string` | `'personal'` or `'corporate'` (derived from spread `is_personal` flag) | +| `is_personal_company` | `bool` | Whether the company is a personal account | +| `is_corporate_company` | `bool` | Whether the company is corporate | +| `is_valid` | `bool` | Whether all required billing fields are filled | +| `is_valid_formatted` | `string` | HTML chip showing Yes/No validation status | + +## Validation + +The `is_valid` accessor checks different required fields based on company type: + +- **Personal**: address, city, state, zip_code, country_id +- **Corporate**: name, tax_id, email, address, country_id, city, state, zip_code + +## Related + +- [User](./user) — users belonging to this company +- [HasSpreadable](/system-reference/backend/entity-traits/model-behavior/has-spreadable) — dynamic JSON field extension +- [RegisterController](/system-reference/backend/http/controllers/auth/register-controller) — creates a company during registration diff --git a/docs/src/pages/system-reference/backend/entities/creator-record.md b/docs/src/pages/system-reference/backend/entities/creator-record.md new file mode 100644 index 000000000..1fbe79260 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/creator-record.md @@ -0,0 +1,40 @@ +--- +sidebarPos: 8 +sidebarTitle: CreatorRecord +--- + +# CreatorRecord + +**File**: `src/Entities/CreatorRecord.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Records the creator of a model instance. Used by the `HasCreator` trait to track which user (and auth guard) created a given record. Timestamps are disabled. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `creator_type` | `string` | Creator model class | +| `creator_id` | `int` | Creator model ID | +| `guard_name` | `string` | Auth guard used at creation time | +| `creatable_type` | `string` | Created model class | +| `creatable_id` | `int` | Created model ID | + +## Relationships + +### `creatable(): MorphTo` + +The model that was created. + +### `creator(): MorphTo` + +The user/entity who created the model. + +## Table + +Resolved from `modularity.tables.creator_records`, defaults to `um_creator_records`. + +## Related + +- [HasCreator](/system-reference/backend/entity-traits/relationships/has-creator) — trait that writes creator records diff --git a/docs/src/pages/system-reference/backend/entities/feature.md b/docs/src/pages/system-reference/backend/entities/feature.md new file mode 100644 index 000000000..38a9e16b5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/feature.md @@ -0,0 +1,42 @@ +--- +sidebarPos: 9 +sidebarTitle: Feature +--- + +# Feature + +**File**: `src/Entities/Feature.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Stores featured/starred content for bucket-based content curation. Each feature record links a target model to a named bucket with a sort position and an optional starred flag. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `featured_id` | `int` | Featured model ID | +| `featured_type` | `string` | Featured model class | +| `position` | `int` | Sort order within the bucket | +| `bucket_key` | `string` | Bucket identifier | +| `starred` | `bool` | Whether the item is starred | + +## Relationships + +### `featured(): MorphTo` + +The model being featured. + +## Scopes + +### `scopeForBucket($query, $bucketKey): Collection` + +Returns all featured models for a given bucket key, filtering out null (deleted) relations. + +## Table + +Resolved from `modularity.features_table`, defaults to `twill_features`. + +## Related + +- [HasBlocks](/system-reference/backend/entity-traits/secondary/has-blocks) — block editor for featured content diff --git a/docs/src/pages/system-reference/backend/entities/file.md b/docs/src/pages/system-reference/backend/entities/file.md new file mode 100644 index 000000000..24909308f --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/file.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 10 +sidebarTitle: File +--- + +# File + +**File**: `src/Entities/File.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` +**Traits**: `HasFactory`, `HasCreator` + +Represents an uploaded non-image file in the file library. Stores the file's UUID-based storage path, original filename, and byte size. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `uuid` | `string` | UUID-based storage path | +| `filename` | `string` | Original filename | +| `size` | `int` | File size in bytes | + +## Accessors + +| Accessor | Type | Description | +|----------|------|-------------| +| `size_for_human` | `string` | Human-readable size (e.g. `"2.4 MB"`) | +| `size_in_mb` | `float` | Size in megabytes | + +## Methods + +### `canDeleteSafely(): bool` + +Returns `true` when the file is not referenced in the `fileables` pivot table. + +### `scopeUnused($query)` + +Returns all files not referenced in any `fileables` record. + +### `mediableFormat(): array` + +Formats the file into the structure expected by the frontend: `id`, `name`, `src`, `original`, `size`, `filesizeInMb`. + +## Related + +- [FileLibraryController](/system-reference/backend/http/controllers/file-library-controller) — manages file uploads and library +- [HasFiles](/system-reference/backend/entity-traits/media/has-files) — attaches files to models diff --git a/docs/src/pages/system-reference/backend/entities/filepond.md b/docs/src/pages/system-reference/backend/entities/filepond.md new file mode 100644 index 000000000..d46abd2dc --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/filepond.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 11 +sidebarTitle: Filepond +--- + +# Filepond + +**File**: `src/Entities/Filepond.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` + +Represents a permanent Filepond file upload attached to a model via a polymorphic relationship. Dispatches notification events on create, update, and delete lifecycle hooks. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `uuid` | `string` | UUID-based storage path | +| `file_name` | `string` | Original filename | +| `filepondable_id` | `int` | Parent model ID | +| `filepondable_type` | `string` | Parent model class | +| `role` | `string` | Upload role (e.g. `avatar`, `attachments`, `document`) | +| `locale` | `string` | Locale for the upload | + +## Boot Events + +| Event | Action | +|-------|--------| +| `created` | Dispatches `FilepondCreated` | +| `updated` | Dispatches `FilepondUpdated` | +| `deleted` | Dispatches `FilepondDeleted` | + +## Relationships + +### `filepondable(): MorphTo` + +The parent model this file is attached to. + +## Methods + +### `mediableFormat(): array` + +Returns a frontend-friendly format with `uuid`, `file_name`, `source` (preview URL), `created_at`, and `file` info from the `FilepondManager`. + +## Related + +- [TemporaryFilepond](./temporary-filepond) — temporary upload before form submission +- [HasFileponds](/system-reference/backend/entity-traits/media/has-fileponds) — attaches Filepond uploads to models +- [FilepondController](/system-reference/backend/http/controllers/filepond-controller) — handles upload/revert/preview diff --git a/docs/src/pages/system-reference/backend/entities/media.md b/docs/src/pages/system-reference/backend/entities/media.md new file mode 100644 index 000000000..f89f0e9df --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/media.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 12 +sidebarTitle: Media +--- + +# Media + +**File**: `src/Entities/Media.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` +**Traits**: `HasFactory`, `HasCreator` + +Represents an uploaded image in the media library. Stores dimensions, alt text, captions, and supports configurable extra metadata fields. Provides formatted output for frontend media pickers and integrates with the Glide image transformation service. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `uuid` | `string` | UUID-based storage path | +| `filename` | `string` | Original filename | +| `alt_text` | `string` | Alt text for accessibility | +| `caption` | `string` | Image caption | +| `width` | `int` | Image width in pixels | +| `height` | `int` | Image height in pixels | + +Additional fields from `modularity.media_library.extra_metadatas_fields` config are merged into fillable at construction time. + +## Accessors + +| Accessor | Type | Description | +|----------|------|-------------| +| `dimensions` | `string` | `"{width}x{height}"` | + +## Methods + +### `scopeUnused($query)` + +Returns all media records not referenced in the `mediables` pivot table. + +### `canDeleteSafely(): bool` + +Returns `true` when the media is not referenced by any model. + +### `isReferenced(): bool` + +Returns `true` when the media is used by at least one model. + +### `mediableFormat(): array` + +Formats the image for the frontend media picker with thumbnail, medium, and original URLs, dimensions, tags, and CRUD action URLs. + +### `getMetadata($name, $fallback = null): mixed` + +Retrieves metadata from the mediable pivot, respecting locale fallback for translatable metadata fields. + +### `replace($fields): void` + +Updates the media record and recalculates crop coordinates in all referencing `mediables` rows when dimensions change. + +### `altTextFrom($filename): string` + +Generates alt text from a filename by stripping extensions and `@2x` suffixes, then converting to title case. + +### `delete(): bool` + +Only deletes if the media can be deleted safely (not referenced). Returns `false` otherwise. + +## Related + +- [MediaLibraryController](/system-reference/backend/http/controllers/media-library-controller) — manages media uploads +- [GlideController](/system-reference/backend/http/controllers/glide-controller) — serves transformed images +- [HasImages](/system-reference/backend/entity-traits/media/has-images) — attaches media to models diff --git a/docs/src/pages/system-reference/backend/entities/model.md b/docs/src/pages/system-reference/backend/entities/model.md new file mode 100644 index 000000000..c2c8a5f28 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/model.md @@ -0,0 +1,53 @@ +--- +sidebarPos: 13 +sidebarTitle: Model +--- + +# Model + +**File**: `src/Entities/Model.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` +**Implements**: `CacheableInterface`, `ModuleableInterface`, `TaggableInterface` +**Traits**: `HasPresenter`, `IsTranslatable`, `ModelHelpers`, `SoftDeletes`, `TaggableTrait`, `LocaleTags`, `Notifiable`, `HasCaching`, `Traitify` + +Abstract base model for all Modularous entities. Every module-generated model extends this class and inherits soft-deletes, tagging, cache management, presenter support, notifications, and trait-hook composition. + +## Built-in Behaviour + +| Feature | Provider | +|---------|----------| +| Soft deletes | `SoftDeletes` | +| Tagging | `TaggableTrait` + `LocaleTags` | +| Cache invalidation | `HasCaching` | +| Presenter pattern | `HasPresenter` | +| Translation support | `IsTranslatable` | +| Notifications | `Notifiable` | +| Trait method hooks | `Traitify` | + +## Methods + +### `getFillable(): array` + +Returns the model's fillable attributes. For translation models that define a `$baseModuleModel`, automatically populates fillable from the base model's `translatedAttributes` plus `locale` and `active`. Also merges fillable fields from `HasAuthorizable` when that trait is present. + +### `tags(): MorphToMany` + +Polymorphic many-to-many relationship to `Tag` via the `tagged` pivot table. Table name is read from `modularity.tables.tagged`. + +### `newInstance($attributes, $exists): static` + +Overrides Laravel's `newInstance` to propagate trait-specific state (e.g. cache warming flags) to freshly constructed instances during `Builder::create()` calls. + +### `setPublishStartDateAttribute($value): void` + +Mutator that defaults `publish_start_date` to the current time when null. + +## Table Name + +All entity models resolve their table name via `modularityConfig('tables.{key}')`, falling back to the parent's default. + +## Related + +- [Entity Traits](/system-reference/backend/entity-traits/overview) — traits composed into models extending this class +- [Repository](/system-reference/repositories) — data access layer for models diff --git a/docs/src/pages/system-reference/backend/entities/nestedset-collection.md b/docs/src/pages/system-reference/backend/entities/nestedset-collection.md new file mode 100644 index 000000000..bda904daa --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/nestedset-collection.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 14 +sidebarTitle: NestedsetCollection +--- + +# NestedsetCollection + +**File**: `src/Entities/NestedsetCollection.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Kalnoy\Nestedset\Collection` + +Custom Eloquent collection for nested-set models. Extends the Kalnoy nested-set collection with a more permissive `toTree()` that handles partial trees where some parent nodes are missing from the result set. + +## Methods + +### `toTree($root = false): NestedsetCollection` + +Builds a tree from the flat list. Requires `id`, `_lft`, and `parent_id` columns. Unlike the parent implementation, nodes whose parent is not in the current result set are promoted to root level rather than being discarded. + +**Parameters** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$root` | `mixed` | Root node ID to scope the tree, or `false` for all roots | + +## Why it Exists + +The upstream `toTree()` assumes the first loaded node is a root. When you load a partial subtree (e.g. only children of node 3), nodes whose `parent_id` is not in the loaded set would be silently dropped. This override treats any node whose `parent_id` is absent from the loaded IDs as a root: + +```php +public function toTree($root = false): NestedsetCollection +{ + // ... + foreach ($this->items as $node) { + if ($node->getParentId() == $root) { + $items[] = $node; + } elseif (! in_array($node->getParentId(), $ids)) { + $items[] = $node; // treat orphaned parent as root + } + } + // ... +} +``` + +## Usage + +`HasNesting::newCollection()` returns this class automatically — you do not instantiate it directly. + +```php +$tree = Category::all()->toTree(); // → NestedsetCollection with children set +``` + +## Related + +- [HasNesting](/system-reference/backend/entity-traits/secondary/has-nesting) — trait that adds nested-set behaviour to models +- `kalnoy/nestedset` — the underlying nested-set package (`NodeTrait`, `Collection`) diff --git a/docs/src/pages/system-reference/backend/entities/overview.md b/docs/src/pages/system-reference/backend/entities/overview.md new file mode 100644 index 000000000..1ab70078a --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/overview.md @@ -0,0 +1,80 @@ +--- +sidebarPos: 1 +sidebarTitle: Entities Overview +--- + +# Entities + +**Namespace**: `Unusualify\Modularity\Entities` +**Location**: `src/Entities/` + +All Eloquent models in the Modularous package. Module-generated models extend `Model` and gain soft-deletes, tagging, caching, presenter support, and trait composition out of the box. + +## Base Classes + +| Class | File | Purpose | +|-------|------|---------| +| [Model](./model) | `Model.php` | Abstract base — soft-deletes, tagging, caching, presenter, notifications | +| [Revision](./revision) | `Revision.php` | Abstract base for revision-tracking models | + +## Auth & User + +| Class | File | Purpose | +|-------|------|---------| +| [User](./user) | `User.php` | Authenticatable user with roles, OAuth, company, API tokens | +| [UserOauth](./user-oauth) | `UserOauth.php` | OAuth provider link record for a user | +| [Profile](./profile) | `Profile.php` | Extended user profile data | +| [Company](./company) | `Company.php` | Organisation/company record with billing info | + +## Media & Files + +| Class | File | Purpose | +|-------|------|---------| +| [File](./file) | `File.php` | Uploaded file record (non-image) | +| [Media](./media) | `Media.php` | Image record with dimensions, alt text, captions | +| [Filepond](./filepond) | `Filepond.php` | Permanent Filepond upload record (morph relation) | +| [TemporaryFilepond](./temporary-filepond) | `TemporaryFilepond.php` | Temporary upload before form submission | + +## Content Building Blocks + +| Class | File | Purpose | +|-------|------|---------| +| [Block](./block) | `Block.php` | Content block with nested children and media | +| [Repeater](./repeater) | `Repeater.php` | Repeatable content attached via morph relation | +| [Tag](./tag) | `Tag.php` | Tag record with locale support | +| [Tagged](./tagged) | `Tagged.php` | Taggable pivot record | + +## Process & Workflow + +| Class | File | Purpose | +|-------|------|---------| +| [Process](./process) | `Process.php` | State-machine workflow instance | +| [ProcessHistory](./process-history) | `ProcessHistory.php` | Audit trail of process status changes | +| [Assignment](./assignment) | `Assignment.php` | Task assignment with status, due date, file attachments | +| [Authorization](./authorization) | `Authorization.php` | Authorizable relationship pivot | +| [CreatorRecord](./creator-record) | `CreatorRecord.php` | Tracks who created a model instance | + +## State & Data + +| Class | File | Purpose | +|-------|------|---------| +| [State](./state) | `State.php` | Translatable state definition (e.g. Draft, Active, Closed) | +| [Stateable](./stateable) | `Stateable.php` | Morph pivot linking a state to a model | +| [Spread](./spread) | `Spread.php` | Dynamic key-value JSON data attached via morph relation | +| [Setting](./setting) | `Setting.php` | Key-value settings with translations and images | +| [Singleton](./singleton) | `Singleton.php` | Single-record model for unique data | + +## Communication + +| Class | File | Purpose | +|-------|------|---------| +| [Chat](./chat) | `Chat.php` | Chat room attached to a model via morph relation | +| [ChatMessage](./chat-message) | `ChatMessage.php` | Individual message with read/pin/star status | + +## Other + +| Class | File | Purpose | +|-------|------|---------| +| [Feature](./feature) | `Feature.php` | Featured/starred content for bucket-based curation | +| [RelatedItem](./related-item) | `RelatedItem.php` | Many-to-many polymorphic related content | +| [NestedsetCollection](./nestedset-collection) | `NestedsetCollection.php` | Extended nested-set tree collection | diff --git a/docs/src/pages/system-reference/backend/entities/process-history.md b/docs/src/pages/system-reference/backend/entities/process-history.md new file mode 100644 index 000000000..014eb65c1 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/process-history.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 16 +sidebarTitle: ProcessHistory +--- + +# ProcessHistory + +**File**: `src/Entities/ProcessHistory.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Audit trail for process status changes. Each record captures who changed the status, the new status, and an optional reason. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `process_id` | `int` | Parent process | +| `status` | `ProcessStatus` | The status recorded (enum cast) | +| `reason` | `string` | Optional reason for the change | +| `user_id` | `int` | User who made the change | + +## Boot Events + +| Event | Action | +|-------|--------| +| `created` | Dispatches `ProcessHistoryCreated` | +| `updated` | Dispatches `ProcessHistoryUpdated` | + +## Relationships + +### `process(): BelongsTo` + +The parent [Process](./process) this history entry belongs to. + +### `user(): BelongsTo` + +The [User](./user) who triggered this status change. + +## Table + +Resolved from `modularity.tables.process_histories`, defaults to `m_process_histories`. + +## Related + +- [Process](./process) — the parent workflow instance diff --git a/docs/src/pages/system-reference/backend/entities/process.md b/docs/src/pages/system-reference/backend/entities/process.md new file mode 100644 index 000000000..eed847209 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/process.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 15 +sidebarTitle: Process +--- + +# Process + +**File**: `src/Entities/Process.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` +**Traits**: `ProcessScopes` + +Tracks a state-machine workflow instance attached to a parent model via a morph relation. The `ProcessStatus` enum drives the available statuses and their presentation (labels, colours, icons, dialog copy). + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `processable_id` | `int` | Parent model ID | +| `processable_type` | `string` | Parent model class | +| `status` | `ProcessStatus` | Current status (enum cast) | +| `reason` | `string` | Optional reason for the current status | + +## Relationships + +### `processable(): MorphTo` + +The parent model this process belongs to. + +### `histories(): HasMany` + +All status-change history records for this process. + +### `lastHistory(): HasOne` + +The most recent history entry (`latest()` on `created_at`). + +## Accessors + +| Accessor | Type | Source | +|----------|------|--------| +| `status_label` | `string` | `ProcessStatus::label()` | +| `status_color` | `string` | `ProcessStatus::color()` | +| `status_icon` | `string` | `ProcessStatus::icon()` | +| `status_card_variant` | `string` | `ProcessStatus::cardVariant()` | +| `status_card_color` | `string` | `ProcessStatus::cardColor()` | +| `status_reason_label` | `string` | `ProcessStatus::statusReasonLabel()` | +| `status_informational_message` | `string` | `ProcessStatus::informationalMessage()` | +| `next_action_label` | `string` | `ProcessStatus::nextActionLabel()` | +| `next_action_color` | `string` | `ProcessStatus::nextActionColor()` | +| `status_dialog_titles` | `array` | Title for each status value | +| `status_dialog_messages` | `array` | Confirmation messages for each status transition | + +## ProcessStatus Enum Values + +`PREPARING`, `WAITING_FOR_CONFIRMATION`, `WAITING_FOR_REACTION`, `REJECTED`, `CONFIRMED` + +## Related + +- [ProcessHistory](./process-history) — audit trail for status changes +- [ProcessController](/system-reference/backend/http/controllers/process-controller) — process management endpoints +- [HasProcesses](/system-reference/backend/entity-traits/processes/has-processes) — adds process support to models diff --git a/docs/src/pages/system-reference/backend/entities/profile.md b/docs/src/pages/system-reference/backend/entities/profile.md new file mode 100644 index 000000000..da5c10f28 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/profile.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 17 +sidebarTitle: Profile +--- + +# Profile + +**File**: `src/Entities/Profile.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` + +Extended profile data for a user. Provides a dedicated record for personal details that sit alongside the core `User` model. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `user_id` | `int` | Owning user | +| `name` | `string` | Display name | +| `surname` | `string` | Surname | +| `phone` | `string` | Phone number | +| `country` | `string` | Country | +| `language` | `string` | Preferred language | +| `timezone` | `string` | Preferred timezone | + +## Relationships + +### `user(): BelongsTo` + +The user this profile belongs to. + +## Related + +- [User](./user) — core user model +- [ProfileController](/system-reference/backend/http/controllers/profile-controller) — profile edit UI diff --git a/docs/src/pages/system-reference/backend/entities/related-item.md b/docs/src/pages/system-reference/backend/entities/related-item.md new file mode 100644 index 000000000..1fa20072b --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/related-item.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 18 +sidebarTitle: RelatedItem +--- + +# RelatedItem + +**File**: `src/Entities/RelatedItem.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Polymorphic many-to-many pivot model for relating any two models to each other. Has no primary key and no auto-incrementing — it uses a composite of the morph columns. Timestamps are disabled. + +## Configuration + +| Property | Value | +|----------|-------| +| `$guarded` | `[]` (mass-assignable) | +| `$primaryKey` | `null` | +| `$incrementing` | `false` | +| `$timestamps` | `false` | + +## Relationships + +### `related(): MorphTo` + +The related model. + +### `subject(): MorphTo` + +The subject model (the model that "has" related items). + +## Table + +Resolved from `modularity.related_table`, defaults to `twill_related`. + +## Related + +- [HasRelated](/system-reference/backend/entity-traits/secondary/has-related) — trait that adds related items to a model diff --git a/docs/src/pages/system-reference/backend/entities/repeater.md b/docs/src/pages/system-reference/backend/entities/repeater.md new file mode 100644 index 000000000..f930f1c6f --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/repeater.md @@ -0,0 +1,123 @@ +--- +sidebarPos: 19 +sidebarTitle: Repeater +--- + +# Repeater + +**File**: `src/Entities/Repeater.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` + +Eloquent model that persists the rows created by the `HasRepeaters` trait. Each row represents one item in a repeater field (e.g., one FAQ entry, one team member). Extends the Modularous `Model` base class and supports soft deletes. + +## Database Table + +Configurable via `modularityConfig('tables.repeaters', 'modularity_repeaters')`. Defaults to `modularity_repeaters`. + +### Columns + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | UUID / auto-increment | No | Primary key (type follows `modularityIncrementsMethod()`) | +| `repeatable_type` | string (UUID morphs) | No | Class of the owning model | +| `repeatable_id` | UUID | No | ID of the owning model | +| `content` | JSON | No | All field values for this repeater item | +| `role` | string | Yes | The input `name` in the module config — identifies which repeater field this row belongs to | +| `locale` | string(6) | No | Locale code (e.g. `'en'`, `'de'`) — indexed | +| `created_at` / `updated_at` | timestamp | — | Standard timestamps | +| `deleted_at` | timestamp | Yes | Soft-delete column | + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `repeatable_id` | `int` | Parent model ID | +| `repeatable_type` | `string` | Parent model class | +| `content` | `array` | Repeater data (JSON cast) | +| `role` | `string` | Input field role identifier | +| `locale` | `string` | Locale for translated repeaters | + +## Relationships + +```php +// Owning model (any model using HasRepeaters) +public function repeatable(): MorphTo +``` + +No reverse eager-loading is defined on `Repeater` itself — access is always from the owning model via `$model->repeaters($role, $locale)`. + +## `getTable()` + +The table name is resolved at runtime: + +```php +public function getTable(): string +{ + return modularityConfig('tables.repeaters', parent::getTable()); +} +``` + +## How `role` and `locale` Work Together + +A single model can have multiple repeater fields. `role` discriminates between them; `locale` discriminates between translations within the same field. + +``` +Page { id: 1 } +├── Repeater { repeatable_id: 1, role: 'faqs', locale: 'en', content: { question: 'What?', answer: '...' } } +├── Repeater { repeatable_id: 1, role: 'faqs', locale: 'en', content: { question: 'How?', answer: '...' } } +├── Repeater { repeatable_id: 1, role: 'faqs', locale: 'de', content: { question: 'Was?', answer: '...' } } +└── Repeater { repeatable_id: 1, role: 'team_members', locale: 'en', content: { name: 'Alice', title: 'CTO' } } +``` + +### Querying by role + locale + +The `HasRepeaters` trait's `repeaters()` method applies these filters: + +```php +// All English FAQs on a page +$page->repeaters('faqs', 'en')->get(); + +// All repeaters regardless of role/locale +$page->repeaters()->get(); +``` + +## `content` JSON Schema + +The `content` column stores all field values for a single repeater item as a flat or nested object, depending on the input schema defined in the module config: + +```json +{ + "question": "What is Modularous?", + "answer": "A Laravel module framework.", + "icon": null +} +``` + +For translated fields (`translated: true` in the schema), values are nested by locale: + +```json +{ + "question": { + "en": "What is Modularous?", + "de": "Was ist Modularous?" + } +} +``` + +## Difference from Block + +| Aspect | Repeater | Block | +|--------|----------|-------| +| Nesting | Flat rows only — no parent/child | One level of parent/child via `parent_id` | +| Type system | No `type` — all rows under a role are the same shape | Each row has a `type` string mapping to a Blade view | +| Rendering | Via PHP/Inertia (frontend handles layout) | Via Blade views (`renderBlocks()`) | +| Media | Not built-in | `HasFiles`, `HasImages` on the Block model | +| Locale | Stored as column (`locale`) | Stored inside `content` JSON per field | +| Table | `modularity_repeaters` (configurable) | `twill_blocks` (configurable) | + +## Related + +- [HasRepeaters](/system-reference/backend/entity-traits/repeaters/has-repeaters) — entity trait and repository trait guide +- [Repeaters developer guide](/guide/module-features/repeaters) — input schema and runtime usage +- [Block](./block) — similar content-storage pattern for the block editor diff --git a/docs/src/pages/system-reference/backend/entities/revision.md b/docs/src/pages/system-reference/backend/entities/revision.md new file mode 100644 index 000000000..8e4c9d7bf --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/revision.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 20 +sidebarTitle: Revision +--- + +# Revision + +**File**: `src/Entities/Revision.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Abstract base for revision-tracking models. Each module that enables the `HasRevisions` trait generates a `{Model}Revision` class extending this. Stores a JSON snapshot of the model's state at the time of the revision. + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$fillable` | `array` | `['payload', 'user_id', '{model}_id']` — foreign key is auto-appended | +| `$with` | `array` | `['user']` — always eager-loads the user who made the change | + +## Constructor + +Automatically appends the parent model's foreign key to `$fillable` by deriving it from the class name (e.g. `PostRevision` → `post_id`). + +## Relationships + +### `user(): BelongsTo` + +The user who created this revision. + +## Accessors + +### `by_user: string` + +Returns the revision author's name, or `'System'` when no user is associated. + +## Related + +- [HasRevisions](/system-reference/backend/entity-traits/secondary/has-revisions) entity trait — adds revision tracking to a model diff --git a/docs/src/pages/system-reference/backend/entities/setting.md b/docs/src/pages/system-reference/backend/entities/setting.md new file mode 100644 index 000000000..bc79cde88 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/setting.md @@ -0,0 +1,42 @@ +--- +sidebarPos: 21 +sidebarTitle: Setting +--- + +# Setting + +**File**: `src/Entities/Setting.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` +**Traits**: `HasImages`, `HasTranslation` + +Key-value settings model with translation support and image attachments. Settings are scoped by `section` and use a `key`/`value` structure where the value is translatable. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `key` | `string` | Setting key | +| `section` | `string` | Setting section/group | + +## Translated Attributes + +| Attribute | Description | +|-----------|-------------| +| `value` | The setting value (per-locale) | +| `locale` | Active locale | +| `active` | Whether the translation is active | + +## Configuration + +- `$useTranslationFallback` is enabled — missing translations fall back to the default locale. +- Translation model: `Unusualify\Modularity\Entities\Translations\SettingTranslation` + +## Table + +Resolved from `modularity.settings_table`, defaults to `twill_settings`. + +## Related + +- [HasImages](/system-reference/backend/entity-traits/media/has-images) — settings can have associated images +- [HasTranslation](/system-reference/backend/entity-traits/translation/has-translation) — translated values diff --git a/docs/src/pages/system-reference/backend/entities/singleton.md b/docs/src/pages/system-reference/backend/entities/singleton.md new file mode 100644 index 000000000..d37a6769b --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/singleton.md @@ -0,0 +1,28 @@ +--- +sidebarPos: 22 +sidebarTitle: Singleton +--- + +# Singleton + +**File**: `src/Entities/Singleton.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Model` + +A single-record model for data that should only ever have one row per type. The `singleton_type` discriminator identifies the kind of singleton, and `content` stores its payload as JSON. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `id` | `int` | Primary key | +| `singleton_type` | `string` | Type discriminator | +| `content` | `array` | Singleton data (JSON cast) | + +## Table + +Resolved from `modularity.tables.singletons`, defaults to `modularity_singletons`. + +## Related + +- [IsSingular](/system-reference/backend/entity-traits/singletons/is-singular) — trait that applies singleton behaviour to a module route diff --git a/docs/src/pages/system-reference/backend/entities/spread.md b/docs/src/pages/system-reference/backend/entities/spread.md new file mode 100644 index 000000000..4dbfce7ad --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/spread.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 23 +sidebarTitle: Spread +--- + +# Spread + +**File**: `src/Entities/Spread.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Stores dynamic key-value JSON data attached to a parent model via a polymorphic relationship. Acts as an extension mechanism to add arbitrary fields to a model without schema changes. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `spreadable_id` | `int` | Parent model ID | +| `spreadable_type` | `string` | Parent model class | +| `content` | `array` | Dynamic data (JSON cast) | + +## Relationships + +### `spreadable(): MorphTo` + +The parent model this spread data belongs to. + +## Table + +Resolved from `modularity.tables.spreads`, defaults to `um_spreads`. + +## Related + +- [HasSpreadable](/system-reference/backend/entity-traits/model-behavior/has-spreadable) — trait that adds spread support to models +- [Company](./company) — uses spreads for flexible billing fields diff --git a/docs/src/pages/system-reference/backend/entities/state.md b/docs/src/pages/system-reference/backend/entities/state.md new file mode 100644 index 000000000..9d1e83089 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/state.md @@ -0,0 +1,38 @@ +--- +sidebarPos: 24 +sidebarTitle: State +--- + +# State + +**File**: `src/Entities/State.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` +**Traits**: `HasTranslation` + +A translatable state definition (e.g. Draft, Active, In Review, Closed). States are linked to models through [Stateable](./stateable) pivot records. Translations are always eager-loaded. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `published` | `bool` | Whether the state is active | +| `code` | `string` | Machine-readable code | +| `icon` | `string` | Icon identifier | +| `color` | `string` | Colour for UI display | + +## Translated Attributes + +| Attribute | Description | +|-----------|-------------| +| `name` | Human-readable state name | +| `active` | Translation active flag | + +## Table + +Resolved from `modularity.tables.states`, defaults to `um_states`. + +## Related + +- [Stateable](./stateable) — morph pivot linking states to models +- [HasStateable](/system-reference/backend/entity-traits/model-behavior/has-stateable) — trait that adds state support to models diff --git a/docs/src/pages/system-reference/backend/entities/stateable.md b/docs/src/pages/system-reference/backend/entities/stateable.md new file mode 100644 index 000000000..fc98407c6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/stateable.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 25 +sidebarTitle: Stateable +--- + +# Stateable + +**File**: `src/Entities/Stateable.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Polymorphic pivot model that links a [State](./state) to any model using the `HasStateable` trait. Timestamps are enabled to track when a state was assigned. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `state_id` | `int` | The state being assigned | +| `stateable_id` | `int` | Target model ID | +| `stateable_type` | `string` | Target model class | + +## Relationships + +### `state(): BelongsTo` + +The state definition. The model class is resolved from `modularity.models.state`, defaulting to `Unusualify\Modularity\Entities\State`. + +## Table + +Resolved from `modularity.tables.stateables`, defaults to `um_stateables`. + +## Related + +- [State](./state) — the state definition +- [HasStateable](/system-reference/backend/entity-traits/model-behavior/has-stateable) — trait that manages state relationships diff --git a/docs/src/pages/system-reference/backend/entities/tag.md b/docs/src/pages/system-reference/backend/entities/tag.md new file mode 100644 index 000000000..13db438d6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/tag.md @@ -0,0 +1,36 @@ +--- +sidebarPos: 26 +sidebarTitle: Tag +--- + +# Tag + +**File**: `src/Entities/Tag.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Cartalyst\Tags\IlluminateTag` + +Tag model extending Cartalyst's tag package with locale support. Tags are shared across models but scoped by namespace and locale. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `name` | `string` | Tag display name | +| `slug` | `string` | URL-safe slug | +| `count` | `int` | Usage count | +| `namespace` | `string` | Tag namespace for scoping | +| `locale` | `string` | Tag locale | + +## Configuration + +Uses [Tagged](./tagged) as the pivot model (`$taggedModel`). + +## Table + +Resolved from `modularity.tables.tags`. + +## Related + +- [Tagged](./tagged) — the pivot model for taggable relationships +- [TagController](/system-reference/backend/http/controllers/tag-controller) — search and creation endpoints +- [Model](./model) — base model that includes `TaggableTrait` diff --git a/docs/src/pages/system-reference/backend/entities/tagged.md b/docs/src/pages/system-reference/backend/entities/tagged.md new file mode 100644 index 000000000..ea0b34c8e --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/tagged.md @@ -0,0 +1,21 @@ +--- +sidebarPos: 27 +sidebarTitle: Tagged +--- + +# Tagged + +**File**: `src/Entities/Tagged.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Cartalyst\Tags\IlluminateTagged` + +Pivot model for the polymorphic tagging relationship. Links a [Tag](./tag) to any taggable model. + +## Table + +Resolved from `modularity.tables.tagged`. + +## Related + +- [Tag](./tag) — the tag record +- [Model](./model) — base model that includes `TaggableTrait` diff --git a/docs/src/pages/system-reference/backend/entities/temporary-filepond.md b/docs/src/pages/system-reference/backend/entities/temporary-filepond.md new file mode 100644 index 000000000..81bc281dd --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/temporary-filepond.md @@ -0,0 +1,33 @@ +--- +sidebarPos: 28 +sidebarTitle: TemporaryFilepond +--- + +# TemporaryFilepond + +**File**: `src/Entities/TemporaryFilepond.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Tracks temporary Filepond uploads before they are promoted to permanent [Filepond](./filepond) records on form submission. Automatically generates a unique folder name on creation. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `file_name` | `string` | Original filename | +| `input_role` | `string` | Form input role this upload belongs to | +| `folder_name` | `string` | Unique folder name (auto-generated via `uniqid`) | + +## Boot Behaviour + +On `creating`, if `folder_name` is null, a unique folder name is generated using `uniqid('', true)`. + +## Table + +Resolved from `modularity.tables.filepond_temporaries`. + +## Related + +- [Filepond](./filepond) — permanent upload record +- [FilepondController](/system-reference/backend/http/controllers/filepond-controller) — upload/revert lifecycle diff --git a/docs/src/pages/system-reference/backend/entities/user-oauth.md b/docs/src/pages/system-reference/backend/entities/user-oauth.md new file mode 100644 index 000000000..e6c00ceb9 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/user-oauth.md @@ -0,0 +1,38 @@ +--- +sidebarPos: 30 +sidebarTitle: UserOauth +--- + +# UserOauth + +**File**: `src/Entities/UserOauth.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Database\Eloquent\Model` + +Stores an OAuth provider link for a user. Each record represents one linked provider (e.g. Google, GitHub) with the provider's unique ID and access token. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `token` | `string` | OAuth access token | +| `provider` | `string` | Provider name (e.g. `google`, `github`) | +| `avatar` | `string` | Avatar URL from the provider | +| `oauth_id` | `string` | Provider-specific user ID | +| `user_id` | `int` | Owning user | + +## Relationships + +### `user(): BelongsTo` + +The Modularous user this OAuth link belongs to. + +## Table + +Resolved from `modularity.tables.user_oauths`, defaults to `um_user_oauths`. + +## Related + +- [User](./user) — parent user model +- [HasOauth](/system-reference/backend/entity-traits/auth/has-oauth) entity trait — adds OAuth methods to the User model +- [LoginController](/system-reference/backend/http/controllers/auth/login-controller) — OAuth authentication flow diff --git a/docs/src/pages/system-reference/backend/entities/user.md b/docs/src/pages/system-reference/backend/entities/user.md new file mode 100644 index 000000000..e6dbe16a5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entities/user.md @@ -0,0 +1,81 @@ +--- +sidebarPos: 29 +sidebarTitle: User +--- + +# User + +**File**: `src/Entities/User.php` +**Namespace**: `Unusualify\Modularity\Entities` +**Extends**: `Illuminate\Foundation\Auth\User` (Authenticatable) +**Implements**: `HasLocalePreference`, `MustVerifyEmail` +**Traits**: `HasApiTokens`, `HasFactory`, `HasRoles`, `IsTranslatable`, `ModelHelpers`, `Notifiable`, `HasFileponds`, `HasOauth`, `CanRegister`, `HasCompany` + +The primary authenticatable user model. Supports roles and permissions (via Spatie), API tokens (Sanctum), OAuth provider linking, company association, avatar via Filepond, and locale preferences. + +## Fillable Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `name` | `string` | First name | +| `surname` | `string` | Last name | +| `email` | `string` | Email address | +| `password` | `string` | Hashed password | +| `company_id` | `int` | Associated company | +| `job_title` | `string` | Job title | +| `language` | `string` | Preferred locale | +| `timezone` | `string` | Preferred timezone | +| `phone` | `string` | Phone number | +| `country_id` | `int` | Country reference | +| `ui_preferences` | `array` | Sidebar/topbar preferences (JSON cast) | +| `published` | `bool` | Active/published status | +| `email_verified_at` | `datetime` | Email verification timestamp | + +## Hidden Attributes + +`password`, `remember_token` + +## Appended Accessors + +| Accessor | Type | Description | +|----------|------|-------------| +| `roles_meta` | `Collection` | Minimal role data (id, name, title) for the current user | +| `is_client` | `bool` | `true` if any role name starts with `client` | +| `is_superadmin` | `bool` | `true` if user has the `superadmin` role | + +## Boot Behaviour + +- **Creating**: sets a default password from `DEFAULT_USER_PASSWORD` env if none provided. +- **Updated**: clears `email_verified_at` when the email changes. +- **Global scope**: always eager-loads `rolesMetaRelation`. + +## Relationships + +### `rolesMetaRelation(): BelongsToMany` + +Lightweight roles relation (only `id`, `name`, `title` columns) for the `roles_meta` accessor. Respects Spatie's team permission configuration. + +## Methods + +### `setImpersonating($id)` / `stopImpersonating()` / `isImpersonating()` + +Session-based impersonation helpers. + +### `sendGeneratePasswordNotification($token)` / `sendPasswordResetNotification($token)` + +Dispatches the corresponding notification with the given token. + +### `preferredLocale(): string` + +Returns the user's `language` or the application's default locale. + +### `avatar: string` *(accessor)* + +Returns the avatar Filepond upload URL or a default anonymous image. + +## Related + +- [Company](./company) — the user's organisation +- [UserOauth](./user-oauth) — linked OAuth providers +- [Profile](./profile) — extended profile data +- [ProfileController](/system-reference/backend/http/controllers/profile-controller) — profile management UI diff --git a/docs/src/pages/system-reference/backend/entity-traits/auth/can-register.md b/docs/src/pages/system-reference/backend/entity-traits/auth/can-register.md new file mode 100644 index 000000000..5546b8583 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/auth/can-register.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 1 +sidebarTitle: CanRegister +--- + +# CanRegister + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Auth\CanRegister` + +Plugs the `User` model into the email-verification registration flow managed by the `Register` facade and `RegisterBrokerManager`. Implements the two contract methods required by `RegisterBroker`. + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getEmailForRegister` | `(): string` | Returns the email address to send the verification link to (`$this->email`) | +| `sendRegisterNotification` | `(string $token, array $parameters = []): void` | Dispatches the `EmailVerification` notification (or a custom class from `modularity.verification_email_class`) | + +--- + +## Configuration + +| Config key | Default | Description | +|------------|---------|-------------| +| `modularity.verification_email_class` | `EmailVerification::class` | Notification class used for the verification email | + +--- + +## Usage + +This trait is placed on the `User` model. You rarely call these methods directly — they are invoked internally by `RegisterBrokerManager` when `Register::sendResetLink()` is called. + +```php +use Unusualify\Modularity\Entities\Traits\Auth\CanRegister; + +class User extends Authenticatable +{ + use CanRegister; +} + +// Triggered internally by the Register facade: +// Register::sendResetLink(['email' => $user->email]); +// → $user->sendRegisterNotification($token); +``` + +See the [Register facade →](../../facades/register) for the full registration flow. diff --git a/docs/src/pages/system-reference/backend/entity-traits/auth/has-oauth.md b/docs/src/pages/system-reference/backend/entity-traits/auth/has-oauth.md new file mode 100644 index 000000000..133bb99b8 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/auth/has-oauth.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 2 +sidebarTitle: HasOauth +--- + +# HasOauth + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Auth\HasOauth` + +Links a `User` to one or more OAuth provider records (`UserOauth`) via a `HasMany` relationship. Provides a helper to create or associate a `UserOauth` record from a Laravel Socialite callback. + +--- + +## Relationships + +```php +public function providers(): HasMany // → UserOauth records (foreign key: user_id) +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `linkProvider` | `(User $oauthUser, string $provider): UserOauth\|false` | Creates a `UserOauth` record from a Socialite user and saves it via `$this->providers()->save()` | + +### `linkProvider` field mapping + +| UserOauth field | Source | +|----------------|--------| +| `token` | `$oauthUser->token` | +| `avatar` | `$oauthUser->avatar` | +| `provider` | `$provider` (string, e.g. `'google'`) | +| `oauth_id` | `$oauthUser->id` | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Auth\HasOauth; + +class User extends Authenticatable +{ + use HasOauth; +} + +// In an OAuth callback controller: +$socialiteUser = Socialite::driver('google')->user(); +$user->linkProvider($socialiteUser, 'google'); + +// Query linked providers: +$user->providers()->where('provider', 'github')->first(); +$user->providers()->get(); // all linked OAuth providers +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/auth/overview.md b/docs/src/pages/system-reference/backend/entity-traits/auth/overview.md new file mode 100644 index 000000000..dea2b02f5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/auth/overview.md @@ -0,0 +1,14 @@ +--- +sidebarPos: 2 +sidebarTitle: Auth Traits +sidebarGroupTitle: Auth Traits +--- + +# Auth Traits + +Two traits under `Auth/` extend the `User` model with registration verification and OAuth provider linking. + +| Trait | Description | +|-------|-------------| +| [CanRegister](./can-register) | Email-verification token dispatch for the registration flow | +| [HasOauth](./has-oauth) | OAuth provider linking via `UserOauth` has-many | diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/change-relationships.md b/docs/src/pages/system-reference/backend/entity-traits/core/change-relationships.md new file mode 100644 index 000000000..c6c449526 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/change-relationships.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 1 +sidebarTitle: ChangeRelationships +--- + +# Core\ChangeRelationships + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Core\ChangeRelationships` + +Lightweight change-tracking for Eloquent relationships during a single request cycle. Used internally by `HasFileponds` and `Core\ModelHelpers` to flag which relationship collections changed so event listeners can react without re-querying. + +--- + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$changedRelationships` | `array` | Map of `relationship name → changed records` | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setChangedRelationships` | `(array $relationships): void` | Replaces the entire changed-relationships map | +| `addChangedRelationships` | `(string $name, mixed $relationship): void` | Adds a single relationship entry | +| `mergeChangedRelationships` | `(string $name, Collection $relationships): void` | Merges new records into an existing entry | +| `getChangedRelationships` | `(): array` | Returns the full map | +| `wasChangedRelationships` | `(string\|array\|null $relationships = null): bool` | Returns `true` if any (or the specified) relationship changed | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Core\ChangeRelationships; + +class Article extends Model +{ + use ChangeRelationships; +} + +// Mark a relationship as changed +$article->addChangedRelationships('images', $newImages); +$article->mergeChangedRelationships('images', $additionalImages); + +// Check in a listener +if ($article->wasChangedRelationships()) { + // some relationship changed +} + +if ($article->wasChangedRelationships('images')) { + // images specifically changed +} + +if ($article->wasChangedRelationships(['images', 'files'])) { + // images OR files changed +} +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/has-cache-dependents.md b/docs/src/pages/system-reference/backend/entity-traits/core/has-cache-dependents.md new file mode 100644 index 000000000..36efcebf4 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/has-cache-dependents.md @@ -0,0 +1,76 @@ +--- +sidebarPos: 2 +sidebarTitle: HasCacheDependents +--- + +# Core\HasCacheDependents + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Core\HasCacheDependents` + +Defines which other model types should have their caches invalidated when this model changes. Merges three sources in priority order: the `RelationshipGraph` (automatic discovery), the model's `$cacheDependents` property, and the `modularity.cache.dependencies` config. + +Used alongside `HasCaching` — `HasCaching` triggers invalidation, `HasCacheDependents` defines the scope. + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getCacheDependents` | `(): array` | Returns the merged list of dependent module names from all three sources | +| `getGraphDiscoveredDependents` | `(): array` | Returns only automatically discovered dependents from the relationship graph | +| `getManualDependents` | `(): array` | Returns only property-defined and config-defined dependents | +| `addCacheDependent` | `(string $module): static` | Adds a runtime cache dependent dynamically | +| `hasCacheDependents` | `(): bool` | Returns `true` if any dependents are registered | + +--- + +## Resolution Order + +1. **Relationship graph** — auto-discovered via `RelationshipGraph::getAffectedModules($modelClass)` +2. **Property** — `protected array $cacheDependents = [...]` on the model +3. **Config** — `config('modularity.cache.dependencies.{ModelClass}')` (full class name key) + +--- + +## Configuration + +```php +// Option 1: Property on the model +class Company extends Model +{ + use HasCaching, HasCacheDependents; + + protected array $cacheDependents = ['press_release', 'invoice']; +} + +// Option 2: Config file (config/modularity.php) +'cache' => [ + 'dependencies' => [ + \App\Models\Company::class => ['press_release'], + ], +], + +// Option 3: Dynamic (runtime) +$company->addCacheDependent('blog_post'); +``` + +--- + +## Usage + +```php +$company = Company::first(); + +// All dependents (merged) +$company->getCacheDependents(); // ['press_release', 'invoice', ...] + +// Only automatic +$company->getGraphDiscoveredDependents(); + +// Only manual +$company->getManualDependents(); + +// Check +$company->hasCacheDependents(); // true +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/has-caching.md b/docs/src/pages/system-reference/backend/entity-traits/core/has-caching.md new file mode 100644 index 000000000..a70adf63b --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/has-caching.md @@ -0,0 +1,63 @@ +--- +sidebarPos: 3 +sidebarTitle: HasCaching +--- + +# Core\HasCaching + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Core\HasCaching` + +Wires up automatic cache invalidation via `CacheObserver` on model events (`created`, `updated`, `deleted`, `restored`, `forceDeleted`). Composites `Cacheable` for the underlying cache key/store management. + +--- + +## Boot Behavior + +`bootHasCaching()` registers `CacheObserver` on the model. The observer fires cache invalidation on every mutating event. + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `shouldCacheInvalidate` | `(): bool` | Override to conditionally suppress cache invalidation (default: `true`) | +| `withoutCacheInvalidation` | `(): static` | Sets `$skipCacheInvalidation = true` on the instance; returns `$this` for chaining | +| `withCacheInvalidation` | `(): static` | Re-enables cache invalidation on the instance; returns `$this` for chaining | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Core\HasCaching; + +class Article extends Model +{ + use HasCaching; +} + +// Bulk update without triggering cache invalidation on each row +Article::all()->each(fn ($a) => $a->withoutCacheInvalidation()->update(['views' => 0])); + +// Conditionally disable based on context +class DraftArticle extends Article +{ + public function shouldCacheInvalidate(): bool + { + return $this->published; // only invalidate when published + } +} +``` + +::: tip Combine with HasCacheDependents +Add `HasCacheDependents` to also invalidate caches of related models when this model changes: +```php +class Company extends Model +{ + use HasCaching, HasCacheDependents; + + protected array $cacheDependents = ['press_release']; +} +``` +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/has-company.md b/docs/src/pages/system-reference/backend/entity-traits/core/has-company.md new file mode 100644 index 000000000..88e9bb8df --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/has-company.md @@ -0,0 +1,104 @@ +--- +sidebarPos: 4 +sidebarTitle: HasCompany +--- + +# Core\HasCompany + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Core\HasCompany` + +Associates a model (typically `User`) with a `Company` record. Auto-creates the `Company` on first save when a `saving_company_name` attribute is present. Appends several computed attributes for company display and billing state. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `creating` | If `saving_company_name` is set, captures it and removes it from attributes | +| `updating` | Removes `saving_company_name` from attributes | +| `saved` | If creating and company name was captured, creates a new `Company` and sets `company_id` via `updateQuietly` | + +--- + +## Relationship + +```php +public function company(): BelongsTo // → Company model +``` + +--- + +## Appended Attributes + +Appended via `initializeHasCompany()` (unless `$noCompanyAppends = true`): + +| Attribute | Type | Description | +|-----------|------|-------------| +| `company_name` | `string` | Company name or empty string | +| `name_with_company` | `string` | `"User Name (Company Name)"` | +| `email_with_company` | `string` | `"email@example.com (Company Name)"` | +| `valid_company` | `bool` | Whether the company is valid | +| `show_billing_banner` | `bool` | `true` if user is a client, company is invalid, and billing is not disabled | + +--- + +## Computed Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `company_exists` | `bool` | Pre-computed from `withExists('company')` global scope | +| `company_type` | `string` | Company type (`companyType`) or `'corporate'` if no company | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeCompanyUser($query)` | Records with a non-null `company_id` | + +--- + +## Global Scopes + +Registers `company_exists` via `addGlobalScopesHasCompany()`. + +--- + +## Configuration + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$savingCompanyFieldName` | `string` | `'saving_company_name'` | Virtual fillable field name that triggers auto-company creation | +| `$noCompanyAppends` | `bool` | `false` | Set to `true` to disable auto-appended company attributes | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Core\HasCompany; + +class User extends Authenticatable +{ + use HasCompany; +} + +// Read company +$user->company; +$user->company_name; +$user->name_with_company; // "Jane Doe (Acme Corp)" +$user->valid_company; // bool + +// Auto-create company on user creation +$user = User::create([ + 'name' => 'Jane', + 'email' => 'jane@example.com', + 'saving_company_name' => 'New Corp', +]); +$user->company; // Company model with name "New Corp" + +// Query +User::companyUser()->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/has-scopes.md b/docs/src/pages/system-reference/backend/entity-traits/core/has-scopes.md new file mode 100644 index 000000000..b1b7c9a7a --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/has-scopes.md @@ -0,0 +1,84 @@ +--- +sidebarPos: 5 +sidebarTitle: HasScopes +--- + +# Core\HasScopes + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Core\HasScopes` + +Provides the standard visibility scopes used across all Modularous models. Integrates with `Traitify` for conditional global scope registration. Also provides `handleScopes` for applying an array of named scopes to a query. + +--- + +## Boot Behavior + +`bootHasScopes()` calls `setFeatureGlobalScopes()`, which discovers and registers all static `addGlobalScopesHasX()` methods from every trait composed into the model. + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopePublished($query)` | Records where `{table}.published = true` | +| `scopeDraft($query)` | Records where `{table}.published = false` | +| `scopeVisible($query)` | Records within `publish_start_date` and `publish_end_date` window (if fillable) | +| `scopePublishedInListings($query)` | Published + visible + `public = true` (if fillable) | +| `scopeBetween($query, $column, $start, $end)` | Records where `$column` falls in the date range | +| `scopeCreatedAtBetween($query, $start, $end)` | Created within the date range | +| `scopeUpdatedAtBetween($query, $start, $end)` | Updated within the date range | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `hasScope` | `(string $scopeName): bool` (static) | Returns `true` if the scope exists as a method, macro, or named scope | +| `handleScopes` | `(Builder $query, array $scopes): Builder` (static) | Applies an associative array of scope names/values to the query; supports `LIKE` (`%column`), negation (`!value`), and array (`whereIn`) | +| `setFeatureGlobalScopes` | `(): void` (static) | Calls all `addGlobalScopesHasX()` static methods and registers their scopes | +| `getUncountableGlobalScopes` | `(): array` (static) | Returns scope names that should be excluded from `COUNT` queries | +| `newCountQuery` | `(): Builder` | Returns a query builder with uncountable scopes removed | + +--- + +## `addGlobalScopes*` Convention + +Traits that need to register global query scopes declare a static method named `addGlobalScopesHasX()` returning an array: + +```php +public static function addGlobalScopesHasX(): array +{ + return [ + 'my_scope_name' => [ + 'scope' => function ($query) { $query->withExists('someRelation'); }, + 'count' => false, // exclude from COUNT queries + ], + ]; +} +``` + +`setFeatureGlobalScopes()` (called from `bootHasScopes`) discovers and registers all such methods automatically. + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Core\HasScopes; + +class Article extends Model +{ + use HasScopes; +} + +Article::published()->get(); +Article::publishedInListings()->paginate(15); +Article::draft()->get(); +Article::createdAtBetween('2024-01-01', '2024-12-31')->get(); + +// Dynamic scope application +$filters = ['published' => true, '%title' => 'laravel']; +Article::handleScopes(Article::query(), $filters)->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/locale-tags.md b/docs/src/pages/system-reference/backend/entity-traits/core/locale-tags.md new file mode 100644 index 000000000..b29e61ace --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/locale-tags.md @@ -0,0 +1,118 @@ +--- +sidebarPos: 6 +sidebarTitle: LocaleTags +--- + +# Core\LocaleTags + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Core\LocaleTags` + +Locale-scoped tagging backed by the shared `tagged` pivot table. Tags are namespaced to the model class and filtered by locale so the same tag slug can exist in multiple languages without collision. Extends the Eloquent tagging convention from Cviebrock/Spatie tagging packages. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `retrieved` | Registers `LocaleTagsCast` on the `locale_tags_payload` field when `$loadLocalizedTags = true` | +| `saving` | Captures `locale_tags_payload` into `$localeTagsUpdatingPayload` and removes from attributes | +| `saved` | If `$localeTagsUpdatingPayload` is set, calls `setLocaleTags` per locale | + +--- + +## Relationship + +```php +public function localeTags(?string $locale = null): MorphToMany +``` + +Returns tags for the given locale (defaults to `app()->getLocale()`). + +--- + +## Static Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$localeTagsField` | `string` | `'locale_tags_payload'` | Virtual fillable field that triggers a tag sync on save | +| `$loadLocalizedTags` | `bool` | `false` | Enable `LocaleTagsCast` on retrieval | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `tagLocale` | `(array\|string $tags, ?string $locale = null): bool` | Adds locale-scoped tags | +| `untagLocale` | `(array\|string\|null $tags = null, ?string $locale = null): bool` | Removes locale-scoped tags (all if `null`) | +| `addLocaleTag` | `(string $name, ?string $locale): void` | Adds a single tag, creating the `Tag` record if needed | +| `removeLocaleTag` | `(string $name, ?string $locale): void` | Removes a single tag | +| `setLocaleTags` | `(array\|string $tags, string $type = 'name', ?string $locale = null): bool` | Syncs the full tag list for a locale (adds missing, removes extra) | +| `allLocaleTags` | `(?string $locale = null): Builder` (static) | Query builder for all tags of this model class in a given locale | +| `localeTagsList` | `(): Collection` | Returns a locale → `Collection<Tag>` map for all active locales | +| `getLocaleTagsDictionary` | `(): array` | Override to provide a custom slug-generation dictionary | +| `generateLocaleTagsSlug` | `(string $name, ?string $locale = null): string` | Generates a locale-aware slug for a tag name | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeWhereLocaleTag($tags, $type, $locale)` | Models that have ALL of the given locale tags | +| `scopeWithLocaleTag($tags, $type, $locale)` | Models that have ANY of the given locale tags | +| `scopeWithoutLocaleTag($tags, $type, $locale)` | Models that have NONE of the given locale tags | + +--- + +## Configuration + +```php +// Enable virtual fillable field (required for save-time tag sync) +protected bool $allowLocaleTagsFillable = true; + +// Enable auto-cast on retrieval +public static bool $loadLocalizedTags = true; +``` + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Core\LocaleTags; + +class Article extends Model +{ + use LocaleTags; + + public static bool $loadLocalizedTags = true; + protected bool $allowLocaleTagsFillable = true; +} + +// Tag in English +$article->tagLocale(['laravel', 'php'], 'en'); + +// Tag in French +$article->tagLocale(['framework'], 'fr'); + +// Sync (add missing, remove extra) +$article->setLocaleTags(['laravel', 'api'], locale: 'en'); + +// Remove all tags for a locale +$article->untagLocale(locale: 'en'); + +// Query +Article::whereLocaleTag('laravel', 'slug', 'en')->get(); +Article::withLocaleTag(['php', 'laravel'], 'slug', 'en')->get(); +Article::withoutLocaleTag('deprecated', 'slug', 'en')->get(); + +// Get all tags by locale +$article->localeTagsList(); +// Collection: ['en' => [...tags], 'fr' => [...tags]] + +// Save via fillable (triggers setLocaleTags per locale) +$article->fill(['locale_tags_payload' => ['en' => ['laravel'], 'fr' => ['php']]]); +$article->save(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/model-helpers.md b/docs/src/pages/system-reference/backend/entity-traits/core/model-helpers.md new file mode 100644 index 000000000..76a96b037 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/model-helpers.md @@ -0,0 +1,86 @@ +--- +sidebarPos: 7 +sidebarTitle: ModelHelpers +--- + +# Core\ModelHelpers + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Core\ModelHelpers` + +The master composition trait. Composes `ManageEloquent`, `ManageModuleRoute`, `HasScopes`, `LogsActivity`, and `ChangeRelationships` into a single `use` statement. Most Modularous modules include this via `ModelHelpers`. + +--- + +## Composed Traits + +| Trait | Provides | +|-------|----------| +| `ManageEloquent` | `getTableColumns`, `definedRelations`, `definedRelationTypes` | +| `ManageModuleRoute` | `getRouteTitleColumnKey`, module route helpers | +| `HasScopes` | `scopePublished`, `scopeVisible`, `scopeDraft`, global scope registration | +| `LogsActivity` | Spatie activity log integration | +| `ChangeRelationships` | `wasChangedRelationships`, change tracking | + +--- + +## Boot Behavior + +`bootModelHelpers()`: +- Enables/disables activity logging based on auth state. +- On `saving`: captures dirty translated attribute values into `$oldTranslations`. +- On `saved`: logs translation changes to the Spatie activity log. + +--- + +## Static Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$defaultAuthorizedModel` | `string` | `User::class` | Default model class for `HasAuthorizable` | +| `$authorizableRolesToCheck` | `array` | `['manager','account-executive']` | Roles bypassing authorization filter | +| `$assignableRolesToCheck` | `array` | `['editor','reporter']` | Roles bypassing assignment filter | +| `$defaultHasCreatorModel` | `string` | `User::class` | Default model class for `HasCreator` | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getTitleValue` | `(): mixed` | Returns the model's human-readable title (uses `getRouteTitleColumnKey`) | +| `getShowFormat` | `(): mixed` | Returns the value used for the admin "show" view | +| `setStateFormatted` | `(?State $state): string` | Returns a Vuetify chip HTML string for the given state | +| `setStateablePreview` | `(State $state): string` | Returns a chip preview for a state | +| `setStateablePreviewNull` | `(): string` | Returns a "No Status" chip | +| `getActivitylogOptions` | `(): LogOptions` | Returns Spatie activity log configuration (logs fillable + translated attributes, dirty only) | +| `lastActivities` | `(): MorphMany` | Returns up to 10 most recent activity log entries with causer eager-loaded | +| `numberOfX` | `(…): int` | Magic `__call` — resolves `numberOfComments()` → `comments()->count()` for any plural relationship | + +--- + +## Magic Accessors + +`__call($method, $arguments)` — Resolves `numberOf{Relation}()` patterns: +```php +$article->numberOfComments(); // → $article->comments()->count() +$article->numberOfImages(); // → $article->images()->count() +``` + +`__get($key)` — Returns values from `$spreadableMutatorAttributes` when set (integration with `HasSpreadable`). + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Core\ModelHelpers; + +class Article extends Model +{ + use ModelHelpers; +} + +$article->getTitleValue(); +$article->numberOfComments(); // magic count +$article->lastActivities; // MorphMany query +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/core/overview.md b/docs/src/pages/system-reference/backend/entity-traits/core/overview.md new file mode 100644 index 000000000..8ac22e062 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/core/overview.md @@ -0,0 +1,19 @@ +--- +sidebarPos: 3 +sidebarTitle: Core Traits +sidebarGroupTitle: Core Traits +--- + +# Core Traits + +The `Core/` namespace contains the low-level plumbing traits that power scopes, caching, change tracking, locale-aware tagging, and shared model helpers. Most top-level traits depend on one or more of these. + +| Trait | Description | +|-------|-------------| +| [ModelHelpers](./model-helpers) | Master composition trait: scopes, routes, activity logging, title helpers | +| [HasScopes](./has-scopes) | Standard visibility scopes and global scope registration convention | +| [HasCaching](./has-caching) | Automatic cache invalidation via `CacheObserver` | +| [HasCacheDependents](./has-cache-dependents) | Cross-model cache dependency graph | +| [HasCompany](./has-company) | Company association with auto-create on save | +| [ChangeRelationships](./change-relationships) | Tracks which relationships changed during a request cycle | +| [LocaleTags](./locale-tags) | Locale-scoped tagging via the `tagged` pivot table | diff --git a/docs/src/pages/system-reference/backend/entity-traits/deprecated.md b/docs/src/pages/system-reference/backend/entity-traits/deprecated.md new file mode 100644 index 000000000..016117b5b --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/deprecated.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 4 +sidebarTitle: Deprecated Aliases +--- + +# Deprecated Trait Aliases + +Two top-level traits exist only for backwards compatibility. Both delegate entirely to their `Core\` counterparts. Do not use them in new code. + +--- + +## HasScopes *(deprecated)* + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasScopes` +**Delegates to**: `Unusualify\Modularity\Entities\Traits\Core\HasScopes` + +::: warning Deprecated +Use `Core\HasScopes` directly. This alias will be removed in a future major version. +::: + +### Migration + +```php +// Before (deprecated) +use Unusualify\Modularity\Entities\Traits\HasScopes; + +// After +use Unusualify\Modularity\Entities\Traits\Core\HasScopes; +``` + +All methods and scopes are identical. See [Core Traits →](./core/overview) for the full reference. + +--- + +## ModelHelpers *(deprecated)* + +**Namespace**: `Unusualify\Modularity\Entities\Traits\ModelHelpers` +**Delegates to**: `Unusualify\Modularity\Entities\Traits\Core\ModelHelpers` + +::: warning Deprecated +Use `Core\ModelHelpers` directly. This alias will be removed in a future major version. +::: + +### Migration + +```php +// Before (deprecated) +use Unusualify\Modularity\Entities\Traits\ModelHelpers; + +// After +use Unusualify\Modularity\Entities\Traits\Core\ModelHelpers; +``` + +All methods and boot behavior are identical. See [Core Traits →](./core/overview) for the full reference. diff --git a/docs/src/pages/system-reference/backend/entity-traits/media/has-fileponds.md b/docs/src/pages/system-reference/backend/entity-traits/media/has-fileponds.md new file mode 100644 index 000000000..6397931c2 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/media/has-fileponds.md @@ -0,0 +1,80 @@ +--- +sidebarPos: 1 +sidebarTitle: HasFileponds +--- + +# HasFileponds + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasFileponds` + +Tracks Filepond temporary file uploads associated with a model. Uses `Core\ChangeRelationships` internally to flag which Filepond collections changed during a save cycle so downstream listeners (e.g., `FilepondManager`) can process or clean up uploads. + +--- + +## Dependencies + +Automatically uses `Core\ChangeRelationships` (mixed in via `use ChangeRelationships`). + +--- + +## Relationship + +```php +public function fileponds(): MorphMany // → Filepond model +``` + +If `$filepondableClass` is set on the model, the morph source is proxied through that class. + +--- + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$deletedFileponds` | `Collection` | Filepond records that were removed in the current save cycle | +| `$newFileponds` | `Collection` | Filepond records added in the current save cycle | +| `$filepondableClass` | `string\|null` | Optional proxy class — use when the model's Filepond records are owned by a related class | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getFileponds` | `(): Filepond[]` | Returns all Filepond records for this model | +| `hasFilepond` | `(?string $role = null): bool` | Checks whether any (or a specific-role) Filepond exists | +| `addFilepondsAsChanged` | `(Collection $fileponds): void` | Merges records into `changedRelationships['fileponds']` | +| `setDeletedFilepondsAsChanged` | `(Collection $fileponds): void` | Replaces the deleted-fileponds tracking collection | +| `mergeDeletedFilepondsAsChanged` | `(Collection $fileponds): void` | Merges into the deleted-fileponds tracking collection | +| `setNewFilepondsAsChanged` | `(Collection $fileponds): void` | Sets the new-fileponds tracking collection | +| `hasDeletedFileponds` | `(): bool` | Returns `true` if any Filepond records were deleted this cycle | +| `hasNewFileponds` | `(): bool` | Returns `true` if any Filepond records were added this cycle | +| `getDeletedFileponds` | `(): Collection` | Returns the deleted-fileponds collection | +| `getNewFileponds` | `(): Collection` | Returns the new-fileponds collection | +| `getFilepondableClass` | `(): Model` | Returns the effective proxy model (or `$this`) | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasFileponds; + +class Article extends Model +{ + use HasFileponds; +} + +// Access all Filepond records +$article->fileponds()->get(); +$article->getFileponds(); + +// Check existence +$article->hasFilepond(); // any role +$article->hasFilepond('gallery'); // specific role + +// Track changes (used internally by form submission pipeline) +$article->addFilepondsAsChanged($newPonds); +$article->hasDeletedFileponds(); // true if any were removed +$article->getDeletedFileponds(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/media/has-files.md b/docs/src/pages/system-reference/backend/entity-traits/media/has-files.md new file mode 100644 index 000000000..504b36b22 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/media/has-files.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 2 +sidebarTitle: HasFiles +--- + +# HasFiles + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasFiles` + +Attaches files from the `File` model via a `MorphToMany` through the `modularity_fileables` pivot table. Locale-aware: each file attachment stores a `role` and `locale` pivot column, so a model can have different files per language. + +--- + +## Relationship + +```php +public function files(): MorphToMany +``` + +Pivot columns: `role`, `locale`. Ordered by pivot `id` ascending. + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `file` | `(string $role, ?string $locale = null, ?File $file = null): ?string` | Returns the public URL for the file in the given role (and locale); falls back to `fallback_locale` when `translatable.use_property_fallback` is `true` | +| `filesList` | `(string $role, ?string $locale = null): array` | Returns an array of public URLs for all files in a role | +| `fileObject` | `(string $role, ?string $locale = null): ?File` | Returns the raw `File` Eloquent model | + +--- + +## Configuration + +| Config key | Default | Description | +|------------|---------|-------------| +| `modularity.tables.fileables` | — | Pivot table name for file attachments | +| `translatable.use_property_fallback` | `false` | Whether to fall back to `fallback_locale` when no file exists for the requested locale | +| `translatable.fallback_locale` | — | Locale used when the primary locale file is missing | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasFiles; + +class Document extends Model +{ + use HasFiles; +} + +// URL for the current locale +$document->file('attachment'); + +// URL for a specific locale +$document->file('brochure', 'fr'); + +// All files for a role +$document->filesList('downloads'); +$document->filesList('downloads', 'de'); + +// Raw File model +$document->fileObject('contract'); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/media/has-images.md b/docs/src/pages/system-reference/backend/entity-traits/media/has-images.md new file mode 100644 index 000000000..034e4c016 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/media/has-images.md @@ -0,0 +1,101 @@ +--- +sidebarPos: 3 +sidebarTitle: HasImages +--- + +# HasImages + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasImages` + +Attaches images from the `Media` model via a `MorphToMany` through the `modularity_mediables` pivot table. Handles crop variants, alt text, captions, video URLs, LQIP placeholders, and social images. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `deleted` / `forceDeleting` | Detaches all media from the pivot table | +| `retrieved` | Sets the `_icon` attribute if an `image` input with `isIcon: true` is found in route inputs | +| `updating` / `saving` | Removes `_icon` from dirty attributes before persisting | + +--- + +## Relationship + +```php +public function medias(): MorphToMany +``` + +Pivot columns: `crop`, `role`, `crop_w`, `crop_h`, `crop_x`, `crop_y`, `lqip_data`, `ratio`, `metadatas` (+ `locale` when translated form fields are enabled). + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `hasImage` | `(string $role, string $crop = 'default'): bool` | Returns `true` if an image is attached for the given role and crop | +| `image` | `(string $role, string $crop = 'default', array $params = [], bool $has_fallback = false, bool $cms = false): ?string` | Returns the image URL for a role/crop; applies transform params | +| `images` | `(string $role, string $crop = 'default', array $params = [], ?string $locale = null): array` | Returns all image URLs for a role | +| `imagesWithCrops` | `(string $role, array $params = []): array` | Returns all image URLs grouped by `media_id` → `crop` | +| `imageAsArray` | `(string $role, string $crop = 'default', array $params = []): array` | Returns a structured array with `src`, `width`, `height`, `alt`, `caption`, `video` | +| `imagesAsArrays` | `(string $role, string $crop = 'default', array $params = []): array` | Same as `imageAsArray` for all images in a role | +| `imagesAsArraysWithCrops` | `(string $role, array $params = []): array` | All images as arrays, grouped by media ID and crop | +| `imageAltText` | `(string $role, ?Media $media = null): string` | Returns the `altText` metadata for the image | +| `imageCaption` | `(string $role, ?Media $media = null): string` | Returns the `caption` metadata | +| `imageVideo` | `(string $role, ?Media $media = null): string` | Returns the `video` metadata URL | +| `imageObject` | `(string $role, string $crop = 'default'): ?Media` | Returns the raw `Media` model | +| `imageObjects` | `(string $role, string $crop = 'default'): Collection` | Returns all `Media` models for a role/crop | +| `cmsImage` | `(string $role, string $crop = 'default', array $params = []): string` | CMS-optimized URL (uses `ImageService::getCmsUrl`) | +| `defaultCmsImage` | `(array $params = []): string` | CMS URL of the first attached media regardless of role | +| `socialImage` | `(string $role, string $crop = 'default', array $params = [], bool $has_fallback = false): ?string` | Social media–optimised URL | +| `lowQualityImagePlaceholder` | `(string $role, string $crop = 'default', array $params = [], bool $has_fallback = false): ?string` | Base64 LQIP string for progressive loading | + +--- + +## Configuration + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `cropParamsKeys` | `array` | `['crop_x','crop_y','crop_w','crop_h']` | Pivot columns extracted for crop transforms | + +Set `media_library.translated_form_fields = true` in `modularity.php` to enable per-locale media. + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasImages; + +class Article extends Model +{ + use HasImages; +} + +// Basic URL retrieval +$article->image('cover'); // default crop +$article->image('cover', 'thumbnail'); // named crop +$article->image('cover', 'default', ['w' => 800]); // with transform params + +// Multiple images +$article->images('gallery'); // array of URLs +$article->imagesWithCrops('hero'); // grouped by crop + +// Structured data for frontend +$article->imageAsArray('hero'); +// ['src' => '...', 'width' => 1200, 'height' => 630, 'alt' => '...', 'caption' => '...', 'video' => ''] + +// Metadata +$article->imageAltText('cover'); +$article->imageCaption('cover'); + +// Special URLs +$article->cmsImage('thumbnail'); +$article->socialImage('og'); +$article->lowQualityImagePlaceholder('hero'); + +// Existence check +if ($article->hasImage('cover')) { ... } +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/media/overview.md b/docs/src/pages/system-reference/backend/entity-traits/media/overview.md new file mode 100644 index 000000000..ca7725e5c --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/media/overview.md @@ -0,0 +1,15 @@ +--- +sidebarPos: 5 +sidebarTitle: Media Traits +sidebarGroupTitle: Media Traits +--- + +# Media Traits + +Three traits handle file and image attachments. They all build on morph pivot tables so any model can attach media without schema changes. + +| Trait | Description | +|-------|-------------| +| [HasImages](./has-images) | Media library images via `MorphToMany` with crop, LQIP, and social URL helpers | +| [HasFiles](./has-files) | File attachments via `MorphToMany` with locale-aware retrieval | +| [HasFileponds](./has-fileponds) | Filepond temp-file tracking with collection change management | diff --git a/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-position.md b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-position.md new file mode 100644 index 000000000..f808c5e24 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-position.md @@ -0,0 +1,64 @@ +--- +sidebarPos: 1 +sidebarTitle: HasPosition +--- + +# HasPosition + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasPosition` + +Manages an integer `position` column for drag-and-drop ordering. Automatically assigns a position on creation and provides a static helper for reordering. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `creating` | If `position` is not set, assigns `max(position) + 1`. If set but exceeds the current max, adjusts to last position. If set within range, increments all records at or above that position by 1 | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setNewOrder` | `(array $ids, int $startOrder = 1): int` (static) | Reorders models given an array of IDs in the desired sequence; returns `1` on success | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeOrdered()` | Orders by `{table}.position ASC` | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasPosition; + +class MenuItem extends Model +{ + use HasPosition; +} + +// All records ordered by position +MenuItem::ordered()->get(); + +// Reorder after a drag-and-drop interaction +// $ids is the new ordered array of IDs sent from the frontend +MenuItem::setNewOrder([3, 1, 4, 2]); + +// Optional: start numbering from a different offset +MenuItem::setNewOrder([3, 1, 4, 2], startOrder: 0); +``` + +::: tip Migration note +Your migration must include: +```php +$table->unsignedInteger('position')->default(0); +``` +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-presenter.md b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-presenter.md new file mode 100644 index 000000000..5aa00e27d --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-presenter.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 2 +sidebarTitle: HasPresenter +--- + +# HasPresenter + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasPresenter` + +Lightweight presenter pattern — wraps the model in a presenter class for display logic. Supports a main presenter and an admin-specific presenter. Presenter instances are cached on the model instance. + +--- + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$presenter` | `string` | FQN of the presenter class (used by `present()`) | +| `$presenterAdmin` | `string` | FQN of the admin presenter class (used by `presentAdmin()`) | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `present` | `(string $presenter = 'presenter'): object` | Returns a presenter instance (defaults to `$this->presenter`); throws if not set or class not found | +| `presentAdmin` | `(): object` | Returns the admin presenter (`$this->presenterAdmin`) | +| `setPresenter` | `(string $presenter, string $presenterProperty = 'presenter'): static` | Sets the presenter class at runtime (no-op if already set) | +| `setPresenterAdmin` | `(string $presenter): static` | Sets the admin presenter class at runtime | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasPresenter; + +class Article extends Model +{ + use HasPresenter; + + protected $presenter = ArticlePresenter::class; + protected $presenterAdmin = ArticleAdminPresenter::class; +} + +// Using the presenter +$article->present()->title(); +$article->present()->formattedDate(); + +// Using the admin presenter +$article->presentAdmin()->statusBadge(); + +// Setting at runtime (e.g., in a transformer) +$article->setPresenter(ArticleApiPresenter::class); +$article->present()->toArray(); +``` + +::: tip Presenter structure +A presenter typically extends a base `Presenter` class and receives `$this->entity` in the constructor: +```php +class ArticlePresenter +{ + public function __construct(protected Article $entity) {} + + public function title(): string + { + return $this->entity->title ?? 'Untitled'; + } +} +``` +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-slug.md b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-slug.md new file mode 100644 index 000000000..60015647b --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-slug.md @@ -0,0 +1,104 @@ +--- +sidebarPos: 3 +sidebarTitle: HasSlug +--- + +# HasSlug + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasSlug` + +Generates and stores URL slugs in a dedicated `{Model}Slug` model. Supports multi-locale slugs, UTF-8 transliteration, and automatic slug suffixing to avoid collisions. Overrides `resolveRouteBinding()` to look up models by slug rather than primary key. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `saved` | Calls `setSlugs()` to generate/update slug records | +| `restored` | Calls `setSlugs($restoring = true)` to re-activate the slug | + +--- + +## Relationship + +```php +public function slugs(): HasMany // → {Model}Slug model, one record per locale +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setSlugs` | `(bool $restoring = false): void` | Generates/updates slug records for all locales from `$slugAttributes` | +| `getSlug` | `(?string $locale = null): string` | Returns the active slug for the locale (falls back to `fallback_locale` when configured) | +| `getActiveSlug` | `(?string $locale = null): ?object` | Returns the active `Slug` record for the locale | +| `getFallbackActiveSlug` | `(): ?object` | Returns the active slug in the fallback locale | +| `getSlugsTable` | `(): string` | Returns the database table name for this model's slugs | +| `getForeignKey` | `(): string` | Returns the foreign key column name used in the slugs table | +| `resolveRouteBinding` | `(mixed $value, ?string $field = null): static` | Resolves the model from a slug value (published + visible scope applied) | +| `getUtf8Slug` | `(string $str, array $options = []): string` | Converts a UTF-8 string to a URL-safe slug using a built-in character map | +| `disableLocaleSlugs` | `(string $locale, int $exceptId = 0): void` | Deactivates all slugs for a locale except the given ID | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeExistsSlug($query, $slug)` | Models with an active slug matching the value in the current locale | +| `scopeExistsInactiveSlug($query, $slug)` | Models with any (active or inactive) matching slug | +| `scopeExistsFallbackLocaleSlug($query, $slug)` | Models with an active slug in the fallback locale | + +--- + +## Computed Attributes + +| Attribute | Description | +|-----------|-------------| +| `slug` | Virtual — returns `getSlug()` for the current locale | + +--- + +## Configuration + +| Property | Type | Description | +|----------|------|-------------| +| `$slugAttributes` | `array` | Fields used to build the slug (e.g. `['title']`). First element is the slug source, additional elements are dependency fields | +| `$slugModelClass` | `string\|null` | Override the auto-resolved `{Model}Slug` class | +| `$slugForeignKey` | `string\|null` | Override the foreign key column name in the slugs table | + +```php +// In your model +protected $slugAttributes = ['title']; +// Or with a dependency (e.g., scoped to a category) +protected $slugAttributes = ['title', 'category_id']; +``` + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasSlug; + +class Post extends Model +{ + use HasSlug; + + protected $slugAttributes = ['title']; +} + +// Reading slugs +$post->slug; // current locale +$post->getSlug(); // same +$post->getSlug('fr'); // French slug + +// Checking existence +Post::existsSlug('my-post')->first(); + +// Route model binding (automatic via resolveRouteBinding) +// Route::get('/posts/{post}', ...) → looks up by slug +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-spreadable.md b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-spreadable.md new file mode 100644 index 000000000..24fedbad4 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-spreadable.md @@ -0,0 +1,88 @@ +--- +sidebarPos: 4 +sidebarTitle: HasSpreadable +--- + +# HasSpreadable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasSpreadable` + +Stores arbitrary non-column attributes in a JSON `Spread` morph record, surfacing them as native model properties via `__get` / `__call` magic. Useful when you need dynamic, schema-free attributes without adding database columns. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `saving` (new record) | Captures `spread_payload` attribute into `$spreadablePayload` for creation | +| `saving` (existing record) | Writes the `spread_payload` directly to the `Spread` record; removes the virtual attribute | +| `created` | Creates the `Spread` morph record with `$spreadablePayload` | +| `retrieved` | Loads `Spread` content; registers each JSON key as a virtual attribute via `$spreadableMutatorAttributes` | +| `saved` | Touches the model's `updated_at` if the Spread changed but the model itself didn't | + +--- + +## Relationship + +```php +public function spreadable(): MorphOne // → Spread model +``` + +--- + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$spreadableSavingKey` | `string` | `'spread_payload'` | The virtual fillable key that triggers a Spread write on save | +| `$spreadableClass` | `string\|null` | `null` | Optional proxy class (morph owner override) | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getSpreadableSavingKey` | `(): string` (static, final) | Returns the virtual attribute name (default `'spread_payload'`) | +| `getSpreadableKeys` | `(): array` | Returns the list of JSON keys currently spread onto the model | +| `hasSpreadable` | `(): bool` | Returns `true` if a `Spread` record exists | + +--- + +## Global Scopes + +Registers `spreadable_exists` via `addGlobalScopesHasSpreadable()`: +- Adds `withExists('spreadable')` to all queries so `$model->spreadable_exists` avoids a lazy-load. + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasSpreadable; + +class Product extends Model +{ + use HasSpreadable; +} + +// Writing spread attributes +$product->spread_payload = ['meta_title' => 'My Product', 'meta_description' => 'Best product ever']; +$product->save(); + +// Reading spread attributes (automatically available after retrieval) +$product->meta_title; // 'My Product' +$product->meta_description; + +// Checking keys +$product->getSpreadableKeys(); // ['meta_title', 'meta_description'] +``` + +::: tip Custom saving key +Override the virtual attribute name in your model: +```php +public static string $spreadableSavingKey = '_extra'; +``` +Then use `$product->_extra = [...]; $product->save();` +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-stateable.md b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-stateable.md new file mode 100644 index 000000000..7bf7be458 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-stateable.md @@ -0,0 +1,126 @@ +--- +sidebarPos: 5 +sidebarTitle: HasStateable +--- + +# HasStateable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasStateable` + +Implements a configurable state machine backed by a `Stateable` morph record and a shared `states` table. Dispatches `StateableUpdated` on every transition and provides appended attributes for the current state. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `saving` | Detects pending stateable update from `stateable_id`; clears fillable helpers | +| `created` | Creates `State` DB records for all `$default_states` and sets the initial state | +| `retrieved` | Sets `stateable_id` from the current `state` relationship | +| `saved` | Calls `updateStateable()` to write the new state and dispatch `StateableUpdated` | + +--- + +## Relationships + +```php +public function state(): HasOneThrough // → State through Stateable +public function stateable(): MorphOne // → Stateable (pivot record) +``` + +--- + +## Appended Attributes + +Appended via `initializeHasStateable()`: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `state_formatted` | `string` | HTML chip `<span>` with the state's color, icon, and translated name | +| `states` | `Collection` | All available `State` records for this model type | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getStates` | `(): Collection` (static) | Returns (and caches for 1 hour) all `State` records for the model's `$default_states` codes | +| `hydrateState` | `(State $state): State` | Fills icon, color, and translations onto a `State` from the model's configuration | +| `getStateAttribute` | `(): ?State` | Returns the current state with configuration applied | +| `getDefaultStates` | `(): array` (static) | Returns the formatted array of all configured states | +| `getInitialState` | `(): ?array` (static) | Returns the initial state definition | +| `getStateConfiguration` | `(string $code): array` (static) | Returns `icon` + `color` for a state code | +| `stateableChanged` | `(): bool` | Returns `true` if the state was changed in the last save | +| `previousStateableState` | `(): ?State` | Returns the state before the last transition | +| `currentStateableState` | `(): ?State` | Returns the state after the last transition | +| `syncStateData` | `(): array` (static) | Creates any `State` DB records that are missing for the model | + +--- + +## Fillable Helpers + +The following virtual fields are merged into `$fillable` and removed before save: + +| Field | Description | +|-------|-------------| +| `initial_stateable` | Set to a state code to override the initial state on creation | +| `stateable_id` | Set to a `State.id` to transition to that state | + +--- + +## Configuration + +```php +// In your model +protected static $default_states = [ + 'draft', + ['code' => 'published', 'icon' => 'mdi-check', 'color' => 'success', 'en' => ['name' => 'Published']], + 'archived', +]; + +protected static $initial_state = 'draft'; +// or as an array: +protected static $initial_state = ['code' => 'draft']; + +// Override the state model (default: Modules\SystemUtility\Entities\State) +protected static $stateModel = State::class; +``` + +--- + +## Events + +`Modules\SystemNotification\Events\StateableUpdated::dispatch($model, $newState, $oldState)` — fired on every state transition. + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasStateable; + +class Article extends Model +{ + use HasStateable; + + protected static $default_states = ['draft', 'published', 'archived']; + protected static $initial_state = 'draft'; +} + +// Transition state +$article->stateable_id = State::where('code', 'published')->value('id'); +$article->save(); + +// Read state +$article->state->code; // 'published' +$article->state_formatted; // HTML chip + +// Check transition +$article->stateableChanged(); // true after a transition +$article->previousStateableState()->code; // 'draft' + +// Sync missing state DB records +Article::syncStateData(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-uuid.md b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-uuid.md new file mode 100644 index 000000000..a9f18d70a --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/has-uuid.md @@ -0,0 +1,67 @@ +--- +sidebarPos: 6 +sidebarTitle: HasUuid +--- + +# HasUuid + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasUuid` + +Replaces the auto-increment primary key with an ordered UUID string. Uses `Str::orderedUuid()` so UUIDs sort chronologically in the database. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `creating` | Generates and sets `Str::orderedUuid()` on the UUID column if not already set | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getUuidColumn` | `(): string` (static) | Returns the UUID column name (default: `'id'`, override via `$uuidColumn`) | +| `getIncrementing` | `(): bool` | Returns `false` — primary key does not auto-increment | +| `getKeyType` | `(): string` | Returns `'string'` — primary key is a string | + +--- + +## Configuration + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$uuidColumn` | `string` | `'id'` | Column name that stores the UUID | + +```php +// Use a non-PK UUID column +public static string $uuidColumn = 'uuid'; +``` + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasUuid; + +class Session extends Model +{ + use HasUuid; +} + +$session = Session::create(['user_id' => 1]); +$session->id; // e.g. "018f1e2a-3bcd-7000-8000-000000000000" + +// Finding by UUID +Session::find('018f1e2a-3bcd-7000-8000-000000000000'); +``` + +::: tip Migration note +Your migration must declare the primary key as `uuid` or `char(36)`: +```php +$table->uuid('id')->primary(); +``` +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/model-behavior/overview.md b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/overview.md new file mode 100644 index 000000000..1e94dc04b --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/model-behavior/overview.md @@ -0,0 +1,18 @@ +--- +sidebarPos: 6 +sidebarTitle: Model Behavior +sidebarGroupTitle: Model Behavior +--- + +# Model Behavior Traits + +These traits modify how individual model instances behave: slug routing, UUID keys, position ordering, dynamic spread attributes, state machines, and presenter patterns. + +| Trait | Description | +|-------|-------------| +| [HasSlug](./has-slug) | Slug generation, locale-aware slug storage, and `resolveRouteBinding` override | +| [HasUuid](./has-uuid) | Ordered UUID primary key replacing auto-increment | +| [HasPosition](./has-position) | Integer `position` column with auto-assignment and drag-and-drop reorder | +| [HasSpreadable](./has-spreadable) | Arbitrary JSON attributes stored in a `Spread` morph record, surfaced as model properties | +| [HasStateable](./has-stateable) | State machine backed by `Stateable` morph and shared `states` table | +| [HasPresenter](./has-presenter) | Lightweight presenter pattern wrapping the model in a display class | diff --git a/docs/src/pages/system-reference/backend/entity-traits/overview.md b/docs/src/pages/system-reference/backend/entity-traits/overview.md new file mode 100644 index 000000000..456d68ac0 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/overview.md @@ -0,0 +1,83 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Entity Traits + +Modularous ships a comprehensive set of Eloquent model traits organized into four groups. Mix and match them on your models to compose the exact feature set needed. + +## Trait Groups + +| Group | Namespace | Purpose | +|-------|-----------|---------| +| [Top-Level](#top-level-traits) | `Unusualify\Modularity\Entities\Traits\` | Core domain behaviors — relationships, media, state, slugs, etc. | +| [Core](#core-traits) | `Unusualify\Modularity\Entities\Traits\Core\` | Low-level plumbing — caching, scopes, change tracking, locale tags | +| [Auth](#auth-traits) | `Unusualify\Modularity\Entities\Traits\Auth\` | Authentication helpers — OAuth linking, email-verification registration | +| [Secondary](#secondary-traits) | `Unusualify\Modularity\Entities\Traits\Secondary\` | Optional extras — nesting, blocks, revisions, related items | + +--- + +## Top-Level Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `Assignable` | [Assignable](./relationships/assignable) | User/role assignment with `assignments()` morph relation | +| `Chatable` | [Chatable](./relationships/chatable) | Auto-created Chat thread + messages per model | +| `HasAuthorizable` | [HasAuthorizable](./relationships/has-authorizable) | Per-record authorization (`Authorization` morph) | +| `HasCreator` | [HasCreator](./relationships/has-creator) | Tracks the User who created the record | +| `HasFileponds` | [HasFileponds](./media/has-fileponds) | Filepond temp-file tracking with collection management | +| `HasFiles` | [HasFiles](./media/has-files) | File attachments via `MorphToMany` to `File` | +| `HasImages` | [HasImages](./media/has-images) | Media library attachments with crop/param helpers | +| `HasPayment` | [HasPayment](./payment/has-payment) | Payment/price state with full status helpers | +| `HasPriceable` | [HasPriceable](./payment/has-priceable) | Base pricing with currency exchange support | +| `HasPosition` | [HasPosition](./model-behavior/has-position) | Auto-position assignment and `setNewOrder()` | +| `HasPresenter` | [HasPresenter](./model-behavior/has-presenter) | Presenter pattern (`present()`, `presentAdmin()`) | +| `HasProcesses` | [HasProcesses](./processes/has-processes) | Approval workflow processes via morph relation | +| `HasRepeaters` | [HasRepeaters](./repeaters/has-repeaters) | Nested repeater blocks (media + filepond + pricing) | +| `HasScopes` *(deprecated)* | [Deprecated →](./deprecated) | Alias for `Core\HasScopes` | +| `HasSlug` | [HasSlug](./model-behavior/has-slug) | Slug generation and `resolveRouteBinding()` via slug | +| `HasSpreadable` | [HasSpreadable](./model-behavior/has-spreadable) | JSON spread attributes as dynamic model properties | +| `HasStateable` | [HasStateable](./model-behavior/has-stateable) | State machine with event dispatch | +| `HasTranslation` | [HasTranslation](./translation/has-translation) | Multi-locale content via `astrotomic/laravel-translatable` | +| `HasUuid` | [HasUuid](./model-behavior/has-uuid) | Auto UUID primary key (`ordered_uuid`) | +| `IsHostable` | [IsHostable](./singletons/is-hostable) | Slug + hostable route resolution across parent hierarchy | +| `IsSingular` | [IsSingular](./singletons/is-singular) | Singleton model stored in shared `modularity_singletons` table | +| `IsTranslatable` | [IsTranslatable](./translation/is-translatable) | Check helper — detects if model uses translations | +| `ModelHelpers` *(deprecated)* | [Deprecated →](./deprecated) | Alias for `Core\ModelHelpers` | +| `Processable` | [Processable](./processes/processable) | Single-process workflow: confirm / reject flow | + +--- + +## Core Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `Core\ModelHelpers` | [ModelHelpers](./core/model-helpers) | Composes scopes, routes, activity logging, title helpers | +| `Core\HasScopes` | [HasScopes](./core/has-scopes) | `published`, `visible`, `draft`, global scope wiring | +| `Core\HasCaching` | [HasCaching](./core/has-caching) | Auto cache invalidation via `CacheObserver` | +| `Core\HasCacheDependents` | [HasCacheDependents](./core/has-cache-dependents) | Cross-model cache dependency graph | +| `Core\HasCompany` | [HasCompany](./core/has-company) | Company association with auto-create on save | +| `Core\ChangeRelationships` | [ChangeRelationships](./core/change-relationships) | Tracks which relationships changed during a request | +| `Core\LocaleTags` | [LocaleTags](./core/locale-tags) | Locale-scoped tagging (`tagLocale`, `untagLocale`) | + +--- + +## Auth Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `Auth\CanRegister` | [CanRegister](./auth/can-register) | Email-verification token dispatch for registration flow | +| `Auth\HasOauth` | [HasOauth](./auth/has-oauth) | OAuth provider linking (`UserOauth` has-many) | + +--- + +## Secondary Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `Secondary\HasBlocks` | [HasBlocks](./secondary/has-blocks) | Content blocks (morph-many, ordered, rendered) | +| `Secondary\HasNesting` | [HasNesting](./secondary/has-nesting) | Nested-set slug traversal and tree save | +| `Secondary\HasRelated` | [HasRelated](./secondary/has-related) | Related-item linking via morph-many pivot | +| `Secondary\HasRelation` | [HasRelation](./secondary/has-relation) | Minimal stub — forceDeleting hook placeholder | +| `Secondary\HasRevisions` | [HasRevisions](./secondary/has-revisions) | Revision history (has-many, descending) | diff --git a/docs/src/pages/system-reference/backend/entity-traits/payment/has-payment.md b/docs/src/pages/system-reference/backend/entity-traits/payment/has-payment.md new file mode 100644 index 000000000..d0477b7f6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/payment/has-payment.md @@ -0,0 +1,113 @@ +--- +sidebarPos: 1 +sidebarTitle: HasPayment +--- + +# HasPayment + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasPayment` + +Full payment lifecycle management. Internally uses `HasPriceable`. Tracks payment relationships and exposes human-readable status computed attributes. Provides global scopes that prefetch payment existence flags to avoid lazy-load queries. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `saving` | Removes virtual payment attributes (`_price`, `priceExcludingVatFormatted`, etc.) | + +--- + +## Relationships + +```php +public function paymentPrices(): MorphMany // → All Price records with role 'payment' +public function paymentPrice(): MorphOne // → Latest Price with role 'payment' +public function initialPayablePrice(): MorphOne // → Oldest Price with role 'payment' +public function payablePrice(): MorphOne // → Latest unpaid Price (no completed payment) +public function paidPrices(): MorphMany // → Prices with a COMPLETED payment +public function providedPrices(): MorphMany // → Prices with a PROVISION payment +public function refundedPrices(): MorphMany // → Prices with a REFUNDED payment +public function payment(): HasOneThrough // → Latest Payment record through Price +public function payments(): HasManyThrough // → All Payment records through Price +``` + +--- + +## Appended Attributes + +Appended via `initializeHasPayment()`: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `is_paid` | `bool` | `true` if at least one completed payment exists | +| `is_unpaid` | `bool` | `true` if there is a payable (unpaid) price | +| `is_partially_paid` | `bool` | `true` if both paid and unpaid conditions are true | +| `is_provided` | `bool` | `true` if a provision payment exists | +| `is_refunded` | `bool` | `true` if a refunded payment exists | +| `payment_status_formatted` | `string` | Vuetify chip HTML with color and label | + +--- + +## Computed Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `total_cost_excluding_vat` | `int` | Sum of `raw_amount` across all payment prices | +| `total_cost_including_vat` | `int` | Sum of `total_amount` across all payment prices | +| `total_cost_excluding_vat_formatted` | `string\|null` | Formatted total (excl. VAT) | +| `total_cost_including_vat_formatted` | `string\|null` | Formatted total (incl. VAT) | +| `initial_price_excluding_vat` | `int` | `raw_amount` of the first payment price | +| `initial_price_excluding_vat_formatted` | `string\|null` | Formatted initial price | +| `payable_price_excluding_vat` | `int\|null` | Current payable price (excl. VAT) | +| `payable_price_including_vat` | `int\|null` | Current payable price (incl. VAT) | + +--- + +## Global Scopes + +Registers via `addGlobalScopesHasPayment()`: +- `paid_prices_exists` — `withExists('paidPrices')` +- `payable_price_exists` — `withExists('payablePrice')` +- `provided_prices_exists` — `withExists('providedPrices')` +- `refunded_prices_exists` — `withExists('refundedPrices')` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getPaymentRelations` | `(): array` | Returns the `$hasPaymentRelations` property as an array of relation names | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasPayment; + +class Order extends Model +{ + use HasPayment; +} + +// Payment relationships +$order->payment; +$order->payments()->latest()->get(); +$order->paidPrices; +$order->paymentPrice; + +// Status checks +$order->is_paid; // true / false +$order->is_partially_paid; +$order->is_refunded; + +// Formatted +$order->payment_status_formatted; // Vuetify chip HTML +$order->total_cost_including_vat_formatted; + +// Raw amounts +$order->total_cost_excluding_vat; // integer +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/payment/has-priceable.md b/docs/src/pages/system-reference/backend/entity-traits/payment/has-priceable.md new file mode 100644 index 000000000..b049b334f --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/payment/has-priceable.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 2 +sidebarTitle: HasPriceable +--- + +# HasPriceable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasPriceable` + +Base pricing trait. Extends `Oobook\Priceable\Traits\HasPriceable` and `HasPriceableMutators` with currency-exchange support, language-based price conversion, and additional query scopes. + +--- + +## Relationships + +```php +public function prices(): MorphMany // → All Price records for this model +public function basePrice(): MorphOne // → Active Price for the current user's currency (with exchange rate applied when configured) +public function originalBasePrice(): MorphOne // → Active Price for user's currency without exchange conversion +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getLanguageBasedPriceFactor` | `(): int` | Returns the rounding factor for language-based price conversion (10^`$languageBasedPricePower`) | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeHasBasePrice($query)` | Models that have at least one base price record | +| `scopeOrderByBasePrice($query, $direction = 'asc', $role = null)` | Orders by the current user currency's base price | +| `scopeOrderByCurrencyPrice($query, $currencyId, $direction = 'asc', $role = null)` | Orders by the price for a specific currency ID | + +--- + +## Configuration + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$languageBasedPricePower` | `int` | `0` | Decimal power for language-based price rounding (e.g. `2` → round to nearest 100) | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasPriceable; + +class Product extends Model +{ + use HasPriceable; +} + +// Base price for the current user's currency +$product->basePrice; // Price model +$product->basePrice->raw_amount; // integer amount + +// Without exchange conversion +$product->originalBasePrice; + +// Ordering +Product::hasBasePrice()->get(); +Product::orderByBasePrice()->get(); +Product::orderByBasePrice('desc')->get(); +Product::orderByCurrencyPrice($eurId)->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/payment/overview.md b/docs/src/pages/system-reference/backend/entity-traits/payment/overview.md new file mode 100644 index 000000000..ce3fee7d0 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/payment/overview.md @@ -0,0 +1,14 @@ +--- +sidebarPos: 7 +sidebarTitle: Payment Traits +sidebarGroupTitle: Payment Traits +--- + +# Payment Traits + +Two complementary traits handle pricing and payment state. `HasPriceable` provides the base pricing layer (extends `oobook/priceable`). `HasPayment` builds on top of it with full payment relationship management and computed status attributes. + +| Trait | Description | +|-------|-------------| +| [HasPriceable](./has-priceable) | Base pricing via `MorphMany` to `Price`, with currency exchange and ordering scopes | +| [HasPayment](./has-payment) | Full payment lifecycle: paid/unpaid/refunded status, payment relationships, formatted attributes | diff --git a/docs/src/pages/system-reference/backend/entity-traits/processes/has-processes.md b/docs/src/pages/system-reference/backend/entity-traits/processes/has-processes.md new file mode 100644 index 000000000..d371482fe --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/processes/has-processes.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 1 +sidebarTitle: HasProcesses +--- + +# HasProcesses + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasProcesses` + +Aggregates `Process` records from configured child (relationship) models into a parent model. Useful when a parent entity (e.g., a `Project`) needs to see all processes across its child entities (e.g., `pressReleasePackages`, `addons`). Uses raw SQL subqueries to avoid standard `HasMany` constraint conflicts. + +--- + +## Configuration + +```php +// In your model, define which child relationships contribute processes: +protected static array $hasProcessesRelationships = [ + 'pressReleasePackages', + 'pressReleasePackageAddons', +]; +``` + +--- + +## Relationships + +```php +public function processes(): HasMany // → All Process records across configured relationships +public function confirmedProcesses(): HasMany // → Confirmed processes only +public function rejectedProcesses(): HasMany // → Rejected processes only +``` + +::: warning Implementation note +These relationships use `Relation::noConstraints()` with a raw SQL subquery. They cannot be used with standard Laravel eager-loading patterns that rely on a single `processable_id` constraint. +::: + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `processableRelationships` | `(): array` (static) | Returns `$hasProcessesRelationships` (or empty array if not defined) | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasProcesses; + +class Project extends Model +{ + use HasProcesses; + + protected static array $hasProcessesRelationships = [ + 'projectTasks', + 'projectMilestones', + ]; + + public function projectTasks(): HasMany + { + return $this->hasMany(ProjectTask::class); + } + + public function projectMilestones(): HasMany + { + return $this->hasMany(ProjectMilestone::class); + } +} + +// All processes across child models +$project->processes()->get(); +$project->confirmedProcesses()->count(); +$project->rejectedProcesses()->latest()->first(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/processes/overview.md b/docs/src/pages/system-reference/backend/entity-traits/processes/overview.md new file mode 100644 index 000000000..65eb4a71c --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/processes/overview.md @@ -0,0 +1,14 @@ +--- +sidebarPos: 8 +sidebarTitle: Process Traits +sidebarGroupTitle: Process Traits +--- + +# Process Traits + +Two traits handle approval and confirmation workflows. `Processable` is for models that go through a **single** approval process (confirm/reject). `HasProcesses` is for parent models that aggregate **multiple** ongoing process records from related child models. + +| Trait | Description | +|-------|-------------| +| [Processable](./processable) | Single-process workflow: preparing → waiting → confirmed / rejected | +| [HasProcesses](./has-processes) | Aggregates multiple `Process` records from configured child relationships | diff --git a/docs/src/pages/system-reference/backend/entity-traits/processes/processable.md b/docs/src/pages/system-reference/backend/entity-traits/processes/processable.md new file mode 100644 index 000000000..802368d47 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/processes/processable.md @@ -0,0 +1,90 @@ +--- +sidebarPos: 2 +sidebarTitle: Processable +--- + +# Processable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Processable` + +Single-process workflow: models go through a `preparing → waiting_for_confirmation → confirmed / rejected` lifecycle. Uses `HasFileponds` (for evidence file uploads) and `ProcessableScopes` for query filtering. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `created` | Creates an initial `Process` record with status `ProcessStatus::PREPARING` | +| `saved` | If `processable_status` is set, calls `setProcessStatus` and touches the model | + +--- + +## Relationships + +```php +public function process(): MorphOne // → active Process record +public function processHistories(): HasManyThrough // → ProcessHistory through Process +public function processHistory(): HasOneThrough // → Latest ProcessHistory through Process +``` + +--- + +## Computed Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `has_process_history` | `bool` | `true` if at least one `ProcessHistory` record exists | +| `process_history_status` | `string\|null` | Status of the latest `ProcessHistory` | +| `process_history_reason` | `string\|null` | Reason from the latest `ProcessHistory` | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setProcessStatus` | `(string $status, ?string $reason = null): void` | Upserts the `Process` record and creates a `ProcessHistory` entry | +| `sendForConfirmation` | `(): void` | Transitions to `WAITING_FOR_CONFIRMATION` | +| `confirm` | `(): void` | Transitions to `CONFIRMED` | +| `reject` | `(string $reason): void` | Transitions to `REJECTED` with a reason | +| `isProcessStatus` | `(ProcessStatus $status): bool` | Checks if current status matches | + +--- + +## Scopes + +Provided by `ProcessableScopes`: + +| Scope | Description | +|-------|-------------| +| `scopePreparing()` | Models with `preparing` status | +| `scopeWaitingForConfirmation()` | Models pending review | +| `scopeConfirmed()` | Models with confirmed process | +| `scopeRejected()` | Models with rejected process | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Processable; + +class Application extends Model +{ + use Processable; +} + +// Workflow transitions +$application->sendForConfirmation(); +$application->confirm(); +$application->reject('Missing required documents'); + +// Check status +$application->isProcessStatus(ProcessStatus::CONFIRMED); // true +$application->process_history_status; + +// Query +Application::waitingForConfirmation()->get(); +Application::confirmed()->latest()->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/relationships/assignable.md b/docs/src/pages/system-reference/backend/entity-traits/relationships/assignable.md new file mode 100644 index 000000000..6cea23a08 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/relationships/assignable.md @@ -0,0 +1,76 @@ +--- +sidebarPos: 1 +sidebarTitle: Assignable +--- + +# Assignable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Assignable` + +Tracks user/role assignments for a model via `Assignment` morph records. Includes `AssignableScopes` for filtering by assignment status. Deletes or force-deletes assignments in sync with the parent model's deletion. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `deleting` | Soft-deletes all `assignments()` (or force-deletes if SoftDeletes is not used) | +| `forceDeleting` | Force-deletes all `assignments()` | + +--- + +## Relationships + +```php +public function assignments(): MorphMany // → Assignment model, all assignments +public function lastAssignment(): MorphOne // → most recent Assignment (latest created_at) +``` + +--- + +## Appended Attributes + +Appended via `initializeAssignable()`: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `active_assignee_name` | `string\|null` | Display name of the current assignee (from `lastAssignment->assignee->name`) | +| `active_assigner_name` | `string\|null` | Display name of the user who made the last assignment | +| `active_assignment_status` | `string\|null` | HTML chip with the last assignment's status, icon, and color | + +--- + +## Scopes + +Provided by `AssignableScopes`: + +| Scope | Description | +|-------|-------------| +| `scopeAssignedTo($userId)` | Models assigned to a specific user | +| `scopeUnassigned()` | Models with no active assignment | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Assignable; + +class Task extends Model +{ + use Assignable; +} + +// Create an assignment +$task->assignments()->create(['assignee_id' => $user->id, 'assigner_id' => $admin->id]); + +// Read last assignment +$task->lastAssignment; +$task->active_assignee_name; +$task->active_assignment_status; + +// Filter queries +Task::assignedTo($userId)->get(); +Task::unassigned()->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/relationships/chatable.md b/docs/src/pages/system-reference/backend/entity-traits/relationships/chatable.md new file mode 100644 index 000000000..ad9c2d54c --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/relationships/chatable.md @@ -0,0 +1,100 @@ +--- +sidebarPos: 2 +sidebarTitle: Chatable +--- + +# Chatable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Chatable` + +Gives every model its own `Chat` thread with full message history, read status tracking, unread counts, and notification dispatch. Uses `ChatableScopes` for query filtering. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `retrieved` | Sets `_chat_id` attribute if chat exists; creates a `Chat` record if the model is persisted and has no chat | +| `created` | Creates the initial `Chat` record | +| `saving` | Removes `_chat_id` from dirty attributes | + +--- + +## Relationships + +```php +public function chat(): MorphOne // → Chat (auto-created) +public function chatMessages(): HasManyThrough // → ChatMessage through Chat +public function creatorChatMessages(): HasManyThrough // → ChatMessages sent by the model's creator +public function latestChatMessage(): HasOneThrough // → Single most recent ChatMessage +public function unreadChatMessages(): HasManyThrough // → Unread messages (is_read = false) +public function unreadChatMessagesForYou(): HasManyThrough // → Unread messages not authored by current user +public function unreadChatMessagesFromClient(): HasManyThrough +public function unreadChatMessagesFromCreator(): HasManyThrough +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `truncateChat` | `(): void` | Deletes and recreates the chat thread (clears all messages) | +| `handleChatableNotification` | `(): void` | Dispatches an `UnreadChatMessage` event and sends a `ChatableUnreadNotification` if the latest message is unread and has been waiting longer than `$chatableNotificationInterval` minutes | +| `numberOfChatMessages` | `(): int` | Count of all chat messages | +| `numberOfUnreadChatMessages` | `(): int` | Count of unread messages | +| `numberOfUnreadChatMessagesForYou` | `(): int` | Count of unread messages not authored by current user | +| `numberOfUnreadChatMessagesFromCreator` | `(): int` | Count of unread messages from the record's creator | +| `numberOfUnreadChatMessagesFromClient` | `(): int` | Count of unread messages from client-role users | +| `numberOfUnansweredCreatorChatMessages` | `(): int` | Returns `1` if the latest message is from the creator and unread, else `0` | + +--- + +## Computed Attributes + +| Attribute | Description | +|-----------|-------------| +| `chat_messages_count` | Total message count | +| `unread_chat_messages_count` | Unread message count | +| `unread_chat_messages_for_you_count` | Unread messages not from current user | +| `unread_chat_messages_from_creator_count` | Unread messages from the record's creator | + +--- + +## Configuration + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$chatableNotificationInterval` | `int` | `60` | Minutes before a notification is re-dispatched for an unanswered message | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Chatable; + +class Order extends Model +{ + use Chatable; +} + +// Chat record (auto-created) +$order->chat; + +// Messages +$order->chatMessages()->latest()->get(); +$order->latestChatMessage; +$order->unreadChatMessages()->count(); + +// Counts +$order->numberOfUnreadChatMessages(); +$order->chat_messages_count; + +// Clear chat history +$order->truncateChat(); + +// Dispatch notification if needed +$order->handleChatableNotification(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/relationships/has-authorizable.md b/docs/src/pages/system-reference/backend/entity-traits/relationships/has-authorizable.md new file mode 100644 index 000000000..c7254e2bc --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/relationships/has-authorizable.md @@ -0,0 +1,112 @@ +--- +sidebarPos: 3 +sidebarTitle: HasAuthorizable +--- + +# HasAuthorizable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasAuthorizable` + +Attaches a per-record `Authorization` morph record, enabling fine-grained ownership and access control on individual model instances. Integrates with Spatie Permissions for role-based access checks. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `saving` | If `authorized_id` is set and valid, marks model as authorizing; clears fillable helpers | +| `saved` | Creates/updates the `Authorization` record; touches `updated_at` if model wasn't otherwise dirty | +| `deleting` / `forceDeleting` | Deletes the associated `Authorization` record | + +--- + +## Relationships + +```php +public function authorizationRecord(): MorphOne // → Authorization +public function authorizedUser(): HasOneThrough // → User through Authorization +``` + +--- + +## Computed Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `is_authorized` | `bool` | `true` if an authorization record exists | +| `authorization_record_exists` | `bool` | Pre-computed via `withExists('authorizationRecord')` global scope | + +--- + +## Fillable Helpers + +| Field | Description | +|-------|-------------| +| `authorized_id` | Set to a user ID to create/update the authorization on save | +| `authorized_type` | Set to a model class string to override the authorized model type | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeHasAuthorization($user)` | Records authorized for the given user (or current auth user) | +| `scopeIsAuthorizedToYou($user)` | Records authorized specifically to the given user's ID | +| `scopeIsAuthorizedToYourRole($user)` | Records authorized to any user sharing the current user's roles | +| `scopeHasAnyAuthorization()` | Records with any non-null authorization | +| `scopeUnauthorized()` | Records with no authorization record | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getAuthorizedModel` | `(): string` | Returns the authorized model class (from record or default) | +| `getDefaultAuthorizedModel` | `(): string` (static) | Returns the default model class (default: `User::class`, override via `$defaultAuthorizedModel`) | +| `hasAuthorizationUsage` | `(?mixed $user = null): bool` | Returns `true` if the current user has permission to manage authorizations on this record | + +--- + +## Global Scopes + +Registers `authorization_record_exists` via `addGlobalScopesHasAuthorizable()`: +- Adds `withExists('authorizationRecord')` so `$model->is_authorized` avoids a lazy-load. + +--- + +## Configuration + +| Property | Type | Description | +|----------|------|-------------| +| `$defaultAuthorizedModel` | `string` | Model class to use when no authorization record exists (default: `User::class`) | +| `$authorizableRolesToCheck` | `array` | Spatie roles that bypass the authorization filter entirely | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasAuthorizable; + +class Report extends Model +{ + use HasAuthorizable; +} + +// Assign authorization +$report->authorized_id = $user->id; +$report->save(); + +// Read +$report->authorizationRecord; +$report->authorizedUser; +$report->is_authorized; // true + +// Query +Report::isAuthorizedToYou()->get(); +Report::hasAuthorization()->get(); +Report::unauthorized()->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/relationships/has-creator.md b/docs/src/pages/system-reference/backend/entity-traits/relationships/has-creator.md new file mode 100644 index 000000000..84a7eae2d --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/relationships/has-creator.md @@ -0,0 +1,112 @@ +--- +sidebarPos: 4 +sidebarTitle: HasCreator +--- + +# HasCreator + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasCreator` + +Records which authenticated user created the model via a `CreatorRecord` morph. Supports custom creator overrides for admin-on-behalf-of workflows. Integrates with Spatie Permissions for company-level access control. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `saving` | Detects `custom_creator_id` override; clears fillable helpers | +| `saved` (new) | Creates `CreatorRecord` for current auth user (or custom creator if set) | +| `saved` (update) | Updates `CreatorRecord` if a custom creator was set | +| `deleting` / `forceDeleting` | Deletes the associated `CreatorRecord` | + +--- + +## Relationships + +```php +public function creatorRecord(): MorphOne // → CreatorRecord +public function creator(): HasOneThrough // → User through CreatorRecord +``` + +--- + +## Fillable Helpers + +| Field | Description | +|-------|-------------| +| `custom_creator_id` | Override the auto-detected creator user ID | +| `custom_creator_type` | Override the creator model class | +| `custom_guard_name` | Override the guard name stored on the record | + +--- + +## Computed Attributes + +| Attribute | Type | Description | +|-----------|------|-------------| +| `creator_record_exists` | `bool` | Pre-computed from `withExists('creatorRecord')` global scope | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeIsCreator($creator_id, $guardName)` | Records created by a specific user ID and guard | +| `scopeIsMyCreation($user, $guardName)` | Records created by the authenticated user | +| `scopeHasAccessToCreation($user, $guardName)` | Records the user can access: own creations + company-scoped | +| `scopeAuthorized($guardName)` | *(deprecated)* Use `scopeHasAccessToCreation` | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getCreatorModel` | `(): string` | Returns the creator model class (from `CreatorRecord` or default) | +| `getDefaultCreatorModel` | `(): string` (static) | Returns the default creator model (override via `$defaultHasCreatorModel`) | +| `getRolesHasAccessToCreation` | `(): array` | Returns roles that see all records (bypass ownership filter) | +| `getCompanyRolesHasAccessToCreation` | `(): array` | Returns company-level roles that see company-scoped records | + +--- + +## Global Scopes + +Registers `creator_record_exists` via `addGlobalScopesHasCreator()`. + +--- + +## Configuration + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$defaultHasCreatorModel` | `string` | `User::class` | Creator model class | +| `$rolesHasAccessToCreation` | `array` | `['admin','manager','editor']` | Roles that bypass ownership filter | +| `$companyRolesHasAccessToCreation` | `array` | `['manager','client-manager']` | Roles that see company-wide records | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasCreator; + +class Article extends Model +{ + use HasCreator; +} + +// Read creator +$article->creator; // User who created this +$article->creatorRecord; + +// Filter queries +Article::isMyCreation()->get(); +Article::isCreator($userId)->get(); +Article::hasAccessToCreation()->get(); // current user's accessible records + +// Override creator (e.g. admin creating on behalf of client) +$article->custom_creator_id = $clientUser->id; +$article->save(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/relationships/overview.md b/docs/src/pages/system-reference/backend/entity-traits/relationships/overview.md new file mode 100644 index 000000000..65cbd6011 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/relationships/overview.md @@ -0,0 +1,16 @@ +--- +sidebarPos: 9 +sidebarTitle: Relationship Traits +sidebarGroupTitle: Relationship Traits +--- + +# Relationship Traits + +These traits wire up assignment, authorization, creator tracking, and chat threads via morph relationships. They are independent of each other and can be used selectively. + +| Trait | Description | +|-------|-------------| +| [Assignable](./assignable) | User/role assignment via `Assignment` morph with status tracking | +| [Chatable](./chatable) | Auto-created `Chat` thread with messages, read status, and notifications | +| [HasAuthorizable](./has-authorizable) | Per-record `Authorization` morph for fine-grained ownership control | +| [HasCreator](./has-creator) | `CreatorRecord` morph that tracks which user created the record | diff --git a/docs/src/pages/system-reference/backend/entity-traits/repeaters/has-repeaters.md b/docs/src/pages/system-reference/backend/entity-traits/repeaters/has-repeaters.md new file mode 100644 index 000000000..0fa47bed1 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/repeaters/has-repeaters.md @@ -0,0 +1,108 @@ +--- +sidebarPos: 1 +sidebarTitle: HasRepeaters +--- + +# HasRepeaters + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasRepeaters` + +Enables nested "repeater" blocks on a model. Each repeater block is its own `Repeater` model instance that can carry images (`HasImages`), files (`HasFiles`), Filepond uploads (`HasFileponds`), and prices (`HasPriceable`). These four traits are automatically composed onto the `Repeater` model — no configuration required. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `retrieved` | Loads `repeaterRoles` (all distinct roles) and `repeaterLocaleRoles` (roles grouped by locale) from the database | + +--- + +## Relationship + +```php +public function repeaters(?string $role = null, ?string $locale = null): MorphMany +``` + +Returns `Repeater` records filtered by optional `role` and `locale`. + +--- + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$repeaterRoles` | `array` | Distinct role names from all repeater records (populated on `retrieved`) | +| `$repeaterLocaleRoles` | `array` | Role names grouped by locale (populated on `retrieved`) | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getRepeaterField` | `(string $field, ?string $locale = null, mixed $default = null): mixed` | Returns a single field value from a repeater; `$field` uses dot-notation: `'role.nested.key'` | +| `getRepeaterRoles` | `(): array` | Returns all distinct roles for this model's repeaters | +| `getRepeaterLocaleRoles` | `(): array` | Returns roles grouped by locale | +| `hasRepeaterRole` | `(string $role): bool` | Returns `true` if any repeater with the given role exists | +| `hasRepeaterLocaleRole` | `(string $role, ?string $locale = null): bool` | Returns `true` if a locale-specific repeater role exists | +| `isRepeaterValueEqual` | `(string $key, string $value, ?string $locale = null): bool` | Checks if any repeater's field matches the given value (key uses dot-notation) | + +--- + +## Usage + +### Basic setup + +```php +use Unusualify\Modularity\Entities\Traits\HasRepeaters; + +class Article extends Model +{ + use HasRepeaters; +} +``` + +### Accessing repeaters + +```php +// All repeaters for a role +$article->repeaters('gallery')->get(); + +// Locale-specific +$article->repeaters('pricing', 'fr')->get(); + +// Check existence +$article->hasRepeaterRole('gallery'); // bool +$article->hasRepeaterLocaleRole('gallery', 'en'); // bool +``` + +### Reading repeater data + +```php +foreach ($article->repeaters('gallery')->get() as $slide) { + $slide->image('photo'); // from HasImages + $slide->file('download'); // from HasFiles + $slide->basePrice; // from HasPriceable + $slide->getRepeaterField('caption'); +} + +// Dot-notation for nested content +$article->getRepeaterField('info.subtitle'); +$article->getRepeaterField('info.subtitle', 'fr'); +``` + +### Checking a value + +```php +$article->isRepeaterValueEqual('status.code', 'active'); +``` + +--- + +## Notes + +- Repeater records are stored in a shared `repeaters` table with `role`, `locale`, `position`, and JSON `content` columns. +- Repeater records are ordered by their `position` column. +- Each `Repeater` model automatically has `HasFiles`, `HasImages`, `HasPriceable`, and `HasFileponds` mixed in. diff --git a/docs/src/pages/system-reference/backend/entity-traits/repeaters/overview.md b/docs/src/pages/system-reference/backend/entity-traits/repeaters/overview.md new file mode 100644 index 000000000..aea0a6208 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/repeaters/overview.md @@ -0,0 +1,11 @@ +--- +sidebarPos: 10 +sidebarTitle: Repeaters +sidebarGroupTitle: Repeaters +--- + +# Repeaters + +| Trait | Description | +|-------|-------------| +| [HasRepeaters](./has-repeaters) | Nested repeater blocks that each carry images, files, Filepond uploads, and prices | diff --git a/docs/src/pages/system-reference/backend/entity-traits/secondary/has-blocks.md b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-blocks.md new file mode 100644 index 000000000..fb24b5fd5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-blocks.md @@ -0,0 +1,53 @@ +--- +sidebarPos: 1 +sidebarTitle: HasBlocks +--- + +# Secondary\HasBlocks + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Secondary\HasBlocks` + +Attaches ordered `Block` morph records for flexible content composition — page builder-style blocks where each block has a name, position, and rendered output. + +--- + +## Relationship + +```php +public function blocks(): MorphMany // → Block, ordered by position ASC +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `renderBlocks` | `(): string` | Renders all blocks to HTML in position order | +| `renderNamedBlocks` | `(string $name, bool $renderChilds = false): string` | Renders only blocks matching the given name; optionally includes child blocks | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Secondary\HasBlocks; + +class Page extends Model +{ + use HasBlocks; +} + +// Query blocks +$page->blocks()->get(); +$page->blocks()->where('name', 'hero')->first(); + +// Render all blocks +echo $page->renderBlocks(); + +// Render a named section +echo $page->renderNamedBlocks('hero'); + +// Include child blocks +echo $page->renderNamedBlocks('content', renderChilds: true); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/secondary/has-nesting.md b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-nesting.md new file mode 100644 index 000000000..8d79de439 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-nesting.md @@ -0,0 +1,51 @@ +--- +sidebarPos: 2 +sidebarTitle: HasNesting +--- + +# Secondary\HasNesting + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Secondary\HasNesting` + +Adds nested-set slug traversal and tree persistence for hierarchical models (e.g., category trees, menu items). Builds full slug paths by walking up the ancestor chain and provides a static method to persist reordered trees. + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getNestedSlug` | `(): string` | Returns the full slug path including all ancestor segments (e.g. `"parent/child/grandchild"`) | +| `getAncestorsSlug` | `(): string` | Returns the ancestor portion of the slug path (excludes own slug) | +| `saveTreeFromIds` | `(array $ids, ?int $parentId = null): void` (static) | Persists a new tree order from a nested ID array (drag-and-drop payload) | +| `flattenTree` | `(array $tree, ?int $parentId = null): array` (static) | Flattens a nested tree array to a flat list with `parent_id` set on each item | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Secondary\HasNesting; + +class Category extends Model +{ + use HasNesting; +} + +// Slug paths +$category->getNestedSlug(); // "electronics/phones/smartphones" +$category->getAncestorsSlug(); // "electronics/phones" + +// Save reordered tree (from drag-and-drop frontend payload) +Category::saveTreeFromIds([ + ['id' => 3, 'children' => [ + ['id' => 5], + ['id' => 7, 'children' => [['id' => 9]]], + ]], + ['id' => 1], +]); + +// Flatten for processing +$flat = Category::flattenTree($nestedArray); +// [['id' => 3, 'parent_id' => null], ['id' => 5, 'parent_id' => 3], ...] +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/secondary/has-related.md b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-related.md new file mode 100644 index 000000000..9c83e6423 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-related.md @@ -0,0 +1,58 @@ +--- +sidebarPos: 3 +sidebarTitle: HasRelated +--- + +# Secondary\HasRelated + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Secondary\HasRelated` + +Links related content via a `RelatedItem` morph-many pivot. Supports named "browser" contexts so a model can have multiple independent related-item groups (e.g., `related_articles`, `similar_products`). + +--- + +## Relationship + +```php +public function relatedItems(): MorphMany // → RelatedItem records +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getRelated` | `(string $browserName): Collection` | Returns the related models for the given browser name | +| `loadRelated` | `(string $browserName): void` | Eager-loads related items into the model's relation cache | +| `saveRelated` | `(string $browserName, array $ids): void` | Syncs related items for the given browser name to the provided ID list | +| `clearRelated` | `(string $browserName): void` | Removes all related items for the given browser name | +| `clearAllRelated` | `(): void` | Removes all related items for this model | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Secondary\HasRelated; + +class Article extends Model +{ + use HasRelated; +} + +// Sync related articles (browser = 'related_articles') +$article->saveRelated('related_articles', [2, 5, 8]); + +// Read +$article->getRelated('related_articles'); + +// Eager load +$article->loadRelated('related_articles'); + +// Clear one group +$article->clearRelated('related_articles'); + +// Clear everything +$article->clearAllRelated(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/secondary/has-relation.md b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-relation.md new file mode 100644 index 000000000..9a2c1f428 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-relation.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 4 +sidebarTitle: HasRelation +--- + +# Secondary\HasRelation + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Secondary\HasRelation` + +Minimal stub trait that registers a `forceDeleting` boot hook. Use it as a base for models that need cleanup logic on force-delete without committing to the full `HasRelated` pivot system. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `forceDeleting` | Hook fires before a force-delete — empty by default, intended to be overridden | + +--- + +## Usage + +The trait provides the hook point; add your own cleanup by calling `static::forceDeleting()` in your model's boot method after including the trait: + +```php +use Unusualify\Modularity\Entities\Traits\Secondary\HasRelation; + +class Article extends Model +{ + use HasRelation; + + protected static function boot(): void + { + parent::boot(); + + static::forceDeleting(function (self $model) { + // custom cleanup before force-delete + $model->someRelation()->forceDelete(); + }); + } +} +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/secondary/has-revisions.md b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-revisions.md new file mode 100644 index 000000000..09ac42357 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/secondary/has-revisions.md @@ -0,0 +1,74 @@ +--- +sidebarPos: 5 +sidebarTitle: HasRevisions +--- + +# Secondary\HasRevisions + +**Namespace**: `Unusualify\Modularity\Entities\Traits\Secondary\HasRevisions` + +Stores an ordered revision history as a `HasMany` relationship. Revision records are automatically resolved from the module's `Revisions/` namespace or from the active Twill capsule. + +--- + +## Relationship + +```php +public function revisions(): HasMany // → Revision, ordered by created_at DESC +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `revisionsArray` | `(): array` | Returns revisions formatted for CMS views — includes `id`, `author`, `datetime`, and a `label` marking the latest as `"current"` | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeMine($query)` | Filters models that have at least one revision belonging to the currently authenticated CMS user | + +--- + +## Revision Model Resolution + +The trait resolves the revision model in this order: + +1. `{config_namespace}\Models\Revisions\{ModelName}Revision` — if the class exists +2. Falls back to `TwillCapsules::getCapsuleForModel()->getRevisionModel()` + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\Secondary\HasRevisions; + +class Article extends Model +{ + use HasRevisions; +} + +// Eager-load revisions (newest first) +$article->load('revisions'); + +// Access revision history +foreach ($article->revisions as $revision) { + echo $revision->user->name . ' at ' . $revision->created_at; +} + +// CMS-formatted array +$history = $article->revisionsArray(); +// [ +// ['id' => 5, 'author' => 'Alice', 'datetime' => '2024-...', 'label' => 'current'], +// ['id' => 4, 'author' => 'Bob', 'datetime' => '2024-...', 'label' => ''], +// ] + +// Scope: models edited by current user +Article::mine()->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/secondary/overview.md b/docs/src/pages/system-reference/backend/entity-traits/secondary/overview.md new file mode 100644 index 000000000..9914fa29f --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/secondary/overview.md @@ -0,0 +1,17 @@ +--- +sidebarPos: 11 +sidebarTitle: Secondary Traits +sidebarGroupTitle: Secondary Traits +--- + +# Secondary Traits + +Optional extras under `Secondary/` — add content blocks, hierarchical nesting, revision history, and related-item linking. + +| Trait | Description | +|-------|-------------| +| [HasBlocks](./has-blocks) | Ordered `Block` morph records for flexible content composition | +| [HasNesting](./has-nesting) | Nested-set slug traversal and tree persistence | +| [HasRelated](./has-related) | Related-item linking via `RelatedItem` morph-many pivot | +| [HasRelation](./has-relation) | Minimal stub with a `forceDeleting` hook placeholder | +| [HasRevisions](./has-revisions) | Revision history stored as `Revision` has-many records | diff --git a/docs/src/pages/system-reference/backend/entity-traits/singletons/is-hostable.md b/docs/src/pages/system-reference/backend/entity-traits/singletons/is-hostable.md new file mode 100644 index 000000000..3c48a1b02 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/singletons/is-hostable.md @@ -0,0 +1,93 @@ +--- +sidebarPos: 1 +sidebarTitle: IsHostable +--- + +# IsHostable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\IsHostable` + +Extends `HasSlug` and `Core\ModelHelpers` to support multi-level hostable slug routing. Traverses `BelongsTo` and `HasMany` relationship chains to build full slug paths including all ancestor segments. Useful for nested page hierarchies where each level has its own slug. + +--- + +## Dependencies + +Automatically composes: +- `HasSlug` — slug storage and route binding +- `Core\ModelHelpers` — `definedRelations` introspection + +--- + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$hostableColumn` | `string` | `'url'` | Column name that stores the hostable URL segment | + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `hostables` | `(): Collection` (static) | Returns all hostable, published models (applies `hostable` + `published` scopes) | +| `getHostableColumn` | `(): string` | Returns the column name for the hostable URL | +| `hostableRouteArguments` | `(): array` | Builds the full route parameter array by traversing parent `BelongsTo` relationships — e.g. `['parent' => 'about-us', 'page' => 'team']` | +| `hostableParents` | `(): array` | Returns parent model instances that also use `IsHostable` | +| `hostableParentRecords` | `(): array` | Returns the actual parent model records | +| `hostableChilds` | `(): array` | Returns child model classes that use `IsHostable` via `HasMany` | +| `hostableChildRouteParameters` | `(): array` | Returns child route parameter placeholders | +| `hostableRouteBindingParameter` | `(): string` (static) | Returns the route parameter placeholder for this model (e.g. `{page}`) | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeHostable($query)` | Records with a non-null hostable column and no parent hostable (top-level) | +| `scopeNotParentHostable($query)` | Records where the parent model has no hostable URL | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\IsHostable; + +class Page extends Model +{ + use IsHostable; + + public function parent(): BelongsTo + { + return $this->belongsTo(Page::class); + } + + public function children(): HasMany + { + return $this->hasMany(Page::class, 'parent_id'); + } +} + +// Route generation — builds full slug path +$page->hostableRouteArguments(); +// ['parent' => 'about-us', 'page' => 'team'] + +// Query +Page::hostable()->get(); +Page::notParentHostable()->get(); + +// All published hostable pages +Page::hostables(); +``` + +::: tip Route binding +Because `IsHostable` composes `HasSlug`, route model binding works by slug automatically. Define your routes with the model's route parameter placeholder: +```php +Route::get('/{page}', PageController::class); +// or for nested: +Route::get('/{parent}/{page}', PageController::class); +``` +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/singletons/is-singular.md b/docs/src/pages/system-reference/backend/entity-traits/singletons/is-singular.md new file mode 100644 index 000000000..6bb63aa0f --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/singletons/is-singular.md @@ -0,0 +1,85 @@ +--- +sidebarPos: 2 +sidebarTitle: IsSingular +--- + +# IsSingular + +**Namespace**: `Unusualify\Modularity\Entities\Traits\IsSingular` + +Stores all fillable fields for a model as a JSON blob in the shared `modularity_singletons` table. There is exactly one record per model type — no dedicated table or migration is required. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `creating` | Sets `singleton_type = static::class`; serializes fillable attributes into `content` JSON; removes individual attributes from the model | +| `updating` | Re-serializes fillable attributes into `content` JSON; removes individual attributes | +| `retrieved` | Deserializes `content` JSON back onto the model as individual attributes; removes `content` and `singleton_type` from the visible attributes | + +Also registers `SingularScope` as a global scope to ensure queries only return the current model type's record. + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `single` | `(): static` (static) | Returns the singleton record, creating it if it doesn't exist | +| `getTable` | `(): string` (final) | Always returns the `modularity_singletons` table name (configurable via `modularity.tables.singletons`) | +| `isPublished` | `(): bool` | Returns the `published` field value (from JSON content) | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopePublished($query)` | Records where `content->published` is `true` | + +--- + +## Configuration + +The table name can be overridden in `config/modularity.php`: +```php +'tables' => [ + 'singletons' => 'modularity_singletons', +] +``` + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\IsSingular; + +class SiteSettings extends Model +{ + use IsSingular; + + protected $fillable = ['site_name', 'logo_url', 'contact_email', 'published']; +} + +// Retrieve (or create) the singleton +$settings = SiteSettings::single(); + +// Read attributes (deserialized from JSON) +$settings->site_name; +$settings->contact_email; + +// Update +$settings->site_name = 'My Platform'; +$settings->save(); + +// Check published state +$settings->isPublished(); +SiteSettings::published()->first(); +``` + +::: info No migration needed +`IsSingular` models share the `modularity_singletons` table. A `type` column discriminates between different singletons. No additional migration is required beyond the package install. +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/singletons/overview.md b/docs/src/pages/system-reference/backend/entity-traits/singletons/overview.md new file mode 100644 index 000000000..be409e23a --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/singletons/overview.md @@ -0,0 +1,14 @@ +--- +sidebarPos: 12 +sidebarTitle: Singleton Traits +sidebarGroupTitle: Singleton Traits +--- + +# Singleton Traits + +These traits handle models that exist as a single instance (site settings, homepage content) or that need hostable/subdomain-aware slug routing across a parent hierarchy. + +| Trait | Description | +|-------|-------------| +| [IsSingular](./is-singular) | Stores all fillable fields as JSON in a shared `modularity_singletons` table | +| [IsHostable](./is-hostable) | Multi-level slug routing across a `BelongsTo`/`HasMany` hierarchy | diff --git a/docs/src/pages/system-reference/backend/entity-traits/translation/has-translation.md b/docs/src/pages/system-reference/backend/entity-traits/translation/has-translation.md new file mode 100644 index 000000000..155b3c185 --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/translation/has-translation.md @@ -0,0 +1,94 @@ +--- +sidebarPos: 1 +sidebarTitle: HasTranslation +--- + +# HasTranslation + +**Namespace**: `Unusualify\Modularity\Entities\Traits\HasTranslation` + +Extends `Astrotomic\Translatable\Translatable` with Modularous-aware overrides for locale-keyed attribute filling, attribute transformation, and translation class resolution. + +--- + +## Boot Behavior + +| Event | Action | +|-------|--------| +| `saving` | When model is a Pivot, runs `handleTranslationAttributes` to route locale-keyed arrays correctly | +| `deleting` / `forceDeleting` | Calls `deleteTranslations()` to remove all translation records | + +--- + +## Configuration + +```php +// In your model +public $translatedAttributes = ['title', 'body', 'slug']; + +// Enable transformed fill (locale-keyed arrays) +protected bool $transformTranslatedAttributes = true; +``` + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `fill` | `(array $attributes): static` | Overrides parent `fill` to route locale-keyed arrays through `handleTranslationAttributes` | +| `setAttribute` | `(string $key, mixed $value): static` | Falls through to Translatable for translated keys; parent for non-translated | +| `translatedAttribute` | `(string $key, ?string $locale = null): mixed` | Returns the translated value for a key; without locale returns a Collection of all translations | +| `getTranslatedAttribute` | `(string $key, ?string $locale = null): mixed` | Returns the translated value for the current (or given) locale via `translate()` | +| `getTranslatedAttributes` | `(): array` | Returns `$this->translatedAttributes` | +| `getTranslationModelNameDefault` | `(): string` | Resolves the Translation model class (`{Model}Translation`) from the module or capsule namespace | +| `getActiveLanguages` | `(): Collection` | Returns all configured locales with their `published` status for this model | +| `hasActiveTranslation` | `(?string $locale = null): bool` | Returns `true` if the model has an active translation for the locale | +| `disableTranslationFilling` | `(): void` | Temporarily disables locale-keyed fill routing | +| `enableTranslationFilling` | `(): void` | Re-enables locale-keyed fill routing | + +--- + +## Scopes + +| Scope | Description | +|-------|-------------| +| `scopeWithActiveTranslations(?string $locale)` | Eager-loads active translations for the given locale | +| `scopeOrderByTranslation(string $key, string $dir = 'ASC', ?string $locale)` | Orders by a translated column via JOIN | +| `scopeOrderByRawByTranslation(string $rawOrder, string $groupBy, ?string $locale)` | Orders by a raw expression on the translations table | + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasTranslation; + +class Article extends Model +{ + use HasTranslation; + + public $translatedAttributes = ['title', 'body']; +} + +// Filling translations +$article->fill([ + 'en' => ['title' => 'Hello', 'body' => 'World'], + 'fr' => ['title' => 'Bonjour', 'body' => 'Monde'], +]); +$article->save(); + +// Reading +$article->title; // current locale +$article->translatedAttribute('title', 'fr'); // 'Bonjour' +$article->getActiveLanguages(); +// [['value' => 'en', 'published' => true], ...] + +// Checking +$article->hasActiveTranslation('fr'); // true + +// Querying +Article::withActiveTranslations()->get(); +Article::withActiveTranslations('de')->get(); +Article::orderByTranslation('title')->get(); +``` diff --git a/docs/src/pages/system-reference/backend/entity-traits/translation/is-translatable.md b/docs/src/pages/system-reference/backend/entity-traits/translation/is-translatable.md new file mode 100644 index 000000000..017925dcb --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/translation/is-translatable.md @@ -0,0 +1,58 @@ +--- +sidebarPos: 2 +sidebarTitle: IsTranslatable +--- + +# IsTranslatable + +**Namespace**: `Unusualify\Modularity\Entities\Traits\IsTranslatable` + +A single-method detection helper. Does not add relationships, boot hooks, or any storage. Use it alongside `HasTranslation` to safely check at runtime whether a model is translatable without assuming the trait is present. + +--- + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `isTranslatable` | `(array\|string\|null $columns = null): bool` | Returns `true` if the model uses `HasTranslation`, has `$translatedAttributes`, and — when `$columns` is provided — those columns are in `$translatedAttributes` | + +### Checks performed + +1. Model uses `Unusualify\Modularity\Entities\Traits\HasTranslation` +2. Model has the `translatedAttributes` property +3. If `$columns` is given, at least one column must be in `translatedAttributes` + +--- + +## Usage + +```php +use Unusualify\Modularity\Entities\Traits\HasTranslation; +use Unusualify\Modularity\Entities\Traits\IsTranslatable; + +class Article extends Model +{ + use HasTranslation, IsTranslatable; + + public $translatedAttributes = ['title', 'body']; +} + +$article->isTranslatable(); // true — model uses HasTranslation +$article->isTranslatable('title'); // true — title is translatable +$article->isTranslatable(['title', 'status']); // true — at least one is translatable +$article->isTranslatable('status'); // false — status is not in translatedAttributes +$article->isTranslatable('status'); // false +``` + +::: tip Usage in generic code +Useful in repositories and transformers that handle both translatable and non-translatable models: +```php +if ($model->isTranslatable()) { + $query->withActiveTranslations(); +} +if ($model->isTranslatable('title')) { + $query->orderByTranslation('title'); +} +``` +::: diff --git a/docs/src/pages/system-reference/backend/entity-traits/translation/overview.md b/docs/src/pages/system-reference/backend/entity-traits/translation/overview.md new file mode 100644 index 000000000..971d072ec --- /dev/null +++ b/docs/src/pages/system-reference/backend/entity-traits/translation/overview.md @@ -0,0 +1,14 @@ +--- +sidebarPos: 13 +sidebarTitle: Translation Traits +sidebarGroupTitle: Translation Traits +--- + +# Translation Traits + +Two traits handle multi-locale content. `HasTranslation` provides full translation storage backed by `astrotomic/laravel-translatable`. `IsTranslatable` is a lightweight detection helper. + +| Trait | Description | +|-------|-------------| +| [HasTranslation](./has-translation) | Multi-locale content via `astrotomic/laravel-translatable` with Modularous overrides | +| [IsTranslatable](./is-translatable) | Single-method helper that detects whether a model uses translations | diff --git a/docs/src/pages/system-reference/backend/events/listener.md b/docs/src/pages/system-reference/backend/events/listener.md new file mode 100644 index 000000000..2136ba102 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/listener.md @@ -0,0 +1,125 @@ +--- +sidebarPos: 2 +sidebarTitle: Listener +--- + +# Listener + +`Unusualify\Modularity\Listeners\Listener` + +Abstract base class for all Modularous listeners. Extend this class when creating listeners for `ModelEvent` subclasses. It handles mail-enabled notification dispatch using a convention-based class resolution. + +## Class Signature + +```php +abstract class Listener +{ + protected bool $mailEnabled = false; + protected array $notificationPaths = []; + + public function __construct() + public function handle($event): void +} +``` + +## Constructor + +On instantiation the listener: + +1. Reads `config('modularity.mail.enabled')` and sets `$mailEnabled`. +2. Adds the `SystemNotification` module's `Notifications/` directory to `$notificationPaths`. + +## Notification Resolution Convention + +When `handle()` is called, the listener derives the notification class name from the event class: + +``` +Event class name → Notification class name +───────────────────────────────────────────────── +OrderShippedEvent → OrderShippedEventNotification +ModelCreated → ModelCreatedNotification +``` + +The lookup scans every directory in `$notificationPaths` for a PHP file whose short class name matches `{EventName}Notification`. + +```php +protected function getNotificationClass($event): ?string +``` + +Returns the fully-qualified class name if found, or `null` if no matching notification exists. + +## `handle()` Method + +```php +public function handle($event): void +``` + +If `$mailEnabled` is `true` and a matching notification class is found, it sends the notification immediately via `Notification::route('mail', ...)`: + +```php +Notification::route('mail', $recipientAddress) + ->notifyNow(new $notificationClass($event->model, $event->serializedData)); +``` + +If mail is disabled or no notification class is found, `handle()` returns without doing anything. + +## Extending Listener + +Create a concrete listener for your module's events: + +```php +namespace App\Modules\Orders\Listeners; + +use Unusualify\Modularity\Listeners\Listener; +use App\Modules\Orders\Events\OrderShippedEvent; + +class OrderShippedListener extends Listener +{ + public function handle(OrderShippedEvent $event): void + { + // Custom logic before calling parent mail dispatch: + if ($event->wasChanged('status')) { + // react to status change + } + + // Delegate to Listener mail dispatch + parent::handle($event); + } +} +``` + +## Adding Notification Paths + +If your module stores notification classes outside the default `SystemNotification` path, register additional paths before `handle()` is called: + +```php +public function __construct() +{ + parent::__construct(); + $this->addNotificationPath(app_path('Modules/Orders/Notifications')); +} +``` + +Or merge multiple paths at once: + +```php +$this->mergeNotificationPaths([ + app_path('Modules/Orders/Notifications'), + app_path('Modules/Billing/Notifications'), +]); +``` + +## Configuration + +| Config key | Type | Effect | +|------------|------|--------| +| `modularity.mail.enabled` | `bool` | When `true`, the listener will attempt to send a notification email. When `false`, `handle()` is a no-op. | + +## Methods Reference + +| Method | Visibility | Description | +|--------|-----------|-------------| +| `addNotificationPath(string $path)` | public | Append a single directory to `$notificationPaths` | +| `mergeNotificationPaths(array $paths)` | public | Merge an array of directories into `$notificationPaths` | +| `getNotificationClass($event)` | protected | Resolve and return the FQN of the matching notification, or `null` | +| `handle($event)` | public | Dispatch the notification mail if enabled | diff --git a/docs/src/pages/system-reference/backend/events/model-event.md b/docs/src/pages/system-reference/backend/events/model-event.md new file mode 100644 index 000000000..a29c933d1 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/model-event.md @@ -0,0 +1,225 @@ +--- +sidebarPos: 3 +sidebarTitle: ModelEvent +outline: deep +--- + +# ModelEvent + +`Unusualify\Modularity\Events\ModelEvent` + +**File**: `src/Events/ModelEvent.php` + +Abstract base class for all model-level events. Extend this class to create events that fire when a model is created, updated, or deleted. It wires up broadcasting support via Laravel Reverb and automatically populates contextual data through four traits. + +## Class Signature + +```php +abstract class ModelEvent +{ + use EventUrls, EventChanges, EventStateable, EventUser; + + public string $modelType; + public string $broadcastService = 'reverb'; + + public function __construct(public $model, public $serializedData = null) +} +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$model` | mixed | The Eloquent model instance that triggered the event | +| `$serializedData` | mixed\|null | Optional pre-serialized payload (used by notification classes) | +| `$modelType` | string | Fully-qualified class name of `$model` | +| `$broadcastService` | string | Broadcast driver; defaults to `'reverb'` | + +## Constructor + +```php +new OrderShipped($order); // model only +new OrderShipped($order, $serializedData); // with pre-serialized payload +``` + +The constructor: + +1. Captures `modelType = get_class($model)`. +2. Runs the four `setup*()` methods from the composed traits (user, URLs, changes, stateable). +3. If the event uses `InteractsWithBroadcasting`, calls `$this->broadcastVia($this->broadcastService)`. + +## Defaults You Get For Free + +| Method | Default return | Override when | +|--------|---------------|---------------| +| `broadcastOn()` | `[PrivateChannel('models.{id}'), Channel('model')]` | You need custom/per-user channels | +| `broadcastAs()` | `'modularity.' . snake-dot class name` | You want a stable wire name | +| `broadcastWhen()` | `true` | You want conditional broadcasting | +| `$broadcastService` | `'reverb'` | You want Pusher/Ably per event | + +## Broadcasting + +`ModelEvent` implements Laravel's `ShouldBroadcast` contract via `InteractsWithBroadcasting`. When the using class includes `InteractsWithBroadcasting`, the driver is set to `$broadcastService` during construction. + +### Channels + +| Channel | Type | Pattern | +|---------|------|---------| +| `models.{model_id}` | Private | Per-model updates | +| `model` | Public | All model updates | + +### Event Name + +The broadcast event name follows the convention: + +``` +modularity.{snake_case_event_without_event_suffix} +``` + +For example, a class named `UserCreatedEvent` broadcasts as `modularity.user.created`. + +This is resolved by: + +```php +public function broadcastAs(): string +{ + return 'modularity.' . Str::replace('_', '.', + Str::replace('_event', '', Str::snake(get_class_short_name($this))) + ); +} +``` + +### Overriding `broadcastWhen` + +```php +class OrderShipped extends ModelEvent implements ShouldBroadcast +{ + use InteractsWithBroadcasting; + + public function broadcastWhen(): bool + { + return $this->wasChanged('status') && $this->model->status === 'shipped'; + } +} +``` + +## Event Traits + +Each trait is set up inside the constructor before broadcasting is configured. See the individual trait pages for full property/method references and usage examples. + +| Trait | What it captures | Page | +|-------|-----------------|------| +| `EventUser` | Authenticated user at fire time | [EventUser →](./traits/event-user) | +| `EventUrls` | Current and previous HTTP URLs | [EventUrls →](./traits/event-urls) | +| `EventChanges` | Dirty attributes and changed relationships | [EventChanges →](./traits/event-changes) | +| `EventStateable` | State machine transition details | [EventStateable →](./traits/event-stateable) | + +## Extending ModelEvent + +```php +use Unusualify\Modularity\Events\ModelEvent; +use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Broadcasting\InteractsWithBroadcasting; + +class OrderShipped extends ModelEvent implements ShouldBroadcast +{ + use InteractsWithBroadcasting; +} +``` + +Dispatching the event: + +```php +event(new OrderShipped($order)); + +// With serialized payload for notification classes: +event(new OrderShipped($order, $serializedData)); +``` + +Listening in your module's `EventServiceProvider`: + +```php +protected $listen = [ + OrderShipped::class => [ + OrderShippedListener::class, + ], +]; +``` + +## Checking Changes in a Listener + +```php +public function handle(OrderShipped $event): void +{ + // Attribute / relationship change (EventChanges) + if ($event->wasChanged('status')) { + // status changed on this save + } + + // State machine transition (EventStateable) + if ($event->hasStateable && $event->stateableChanged) { + $from = $event->previousStateableState; + $to = $event->currentStateableState; + } + + // Who triggered it (EventUser) + if ($event->hasUser()) { + $actor = $event->getUser(); + } +} +``` + +See [Event Traits](./traits/overview) for the full reference on each trait. + +## Broadcast Payload Shape + +Because all four trait properties are `public`, they are automatically serialized into the broadcast payload. Clients listening on `.modularity.*` events receive an object with: + +```jsonc +{ + "model": { /* full model, per broadcastWith() or default */ }, + "modelType": "App\\Models\\Order", + "user": { /* auth user or null */ }, + "recentUrl": "https://app.example.com/admin/orders/42", + "previousUrl": "https://app.example.com/admin/orders", + "changedAttributes": { "status": "shipped" }, + "changedRelationships": {}, + "hasStateable": true, + "stateableChanged": true, + "previousStateableState": "paid", + "currentStateableState": "shipped" +} +``` + +Use `broadcastWith()` on your subclass to trim or reshape the payload. + +```php +public function broadcastWith(): array +{ + return [ + 'id' => $this->model->id, + 'status' => $this->currentStateableState, + 'actor' => $this->user?->name, + ]; +} +``` + +## Subclassing Checklist + +When creating a new broadcast event: + +- [ ] Extend `Unusualify\Modularity\Events\ModelEvent` +- [ ] Implement `Illuminate\Contracts\Broadcasting\ShouldBroadcast` +- [ ] Use `Illuminate\Broadcasting\InteractsWithBroadcasting` +- [ ] (Optional) Override `broadcastOn()` for custom channels +- [ ] (Optional) Override `broadcastWhen()` for conditional dispatch +- [ ] (Optional) Override `broadcastWith()` to shape the payload +- [ ] Register a channel authorization in `routes/channels.php` for private channels + +## Related + +- [Event Traits](./traits/overview) — full reference for `EventUser`, `EventUrls`, `EventChanges`, `EventStateable` +- [Broadcasting Overview](/guide/broadcasting/overview) — setup, channels, Echo integration +- [BroadcastManager](/system-reference/backend/services/broadcast-manager) — build frontend config from a list of event classes +- [Testing Broadcasts](/guide/broadcasting/testing) — `Event::fake()` patterns for broadcast events +- [Broadcasting Troubleshooting](/guide/broadcasting/troubleshooting) — common issues and fixes diff --git a/docs/src/pages/system-reference/backend/events/overview.md b/docs/src/pages/system-reference/backend/events/overview.md new file mode 100644 index 000000000..15ffc0cc9 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/overview.md @@ -0,0 +1,84 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Events & Listeners +--- + +# Events & Listeners + +Modularous ships a set of events that fire at key points in the application lifecycle. Listeners react to those events and, when mail is enabled, automatically resolve and dispatch the matching notification class. + +## Events + +| Class | Namespace | Fired When | +|-------|-----------|------------| +| [ModelEvent](./model-event) | `Unusualify\Modularity\Events` | Abstract base — extended by all model-level events | +| [ModularityUserRegistering](./user-events#modularityuserregistering) | `Unusualify\Modularity\Events` | Just before a new user is persisted | +| [ModularityUserRegistered](./user-events#modularityuserregistered) | `Unusualify\Modularity\Events` | Immediately after a user is created | +| [ModularityUserVerification](./user-events#modularityuserverification) | `Unusualify\Modularity\Events` | When an email-verification request is initiated | +| [VerifiedEmailRegister](./user-events#verifiedemailregister) | `Unusualify\Modularity\Events` | When a user completes registration via verified e-mail | + +## Listeners + +| Class | Namespace | Listens To | +|-------|-----------|------------| +| [Listener](./listener) | `Unusualify\Modularity\Listeners` | Abstract base — extended by concrete listeners | + +## Event Traits + +All `ModelEvent` subclasses automatically gain the following traits: + +| Trait | What it captures | +|-------|-----------------| +| `EventChanges` | Changed model attributes and relationships | +| `EventStateable` | Previous / current stateable state | +| `EventUrls` | Current and previous HTTP request URLs | +| `EventUser` | Authenticated user at the time the event fired | + +## SystemNotification Module Events + +The `SystemNotification` module (in `Modules\SystemNotification\Events\`) defines a second set of domain events that fire on model lifecycle changes. These extend `ModelEvent` and are wired to their own listeners and notification classes: + +| Event | Fired When | +|-------|------------| +| `ModelCreated` | Any model is created | +| `ModelUpdated` | Any model is updated | +| `ModelDeleted` | A model is soft-deleted | +| `ModelRestored` | A soft-deleted model is restored | +| `ModelForceDeleted` | A model is permanently deleted | +| `StateableUpdated` | A model transitions state via `HasStateable` | +| `AssignmentCreated` | A new `Assignment` is created | +| `AssignmentUpdated` | An existing `Assignment` is updated | +| `PaymentCompleted` | A payment completes | +| `PaymentFailed` | A payment fails | +| `UnreadChatMessage` | Unread chat messages exist for a `Chatable` model | + +All these extend `ModelEvent`, so they automatically carry `EventUser`, `EventUrls`, `EventChanges`, and `EventStateable` context. + +→ [System Notifications — event/listener/notification map](/system-reference/backend/notifications/system-notifications) + +--- + +## Architecture Overview + +``` +[Action: model saved / user registers] + │ + ▼ + Event dispatched + (ModelEvent subclass or user event) + │ + ▼ + Laravel event system + │ + ├─► Listener::handle($event) + │ └─ resolves {EventName}Notification + │ sends mail if modularity.mail.enabled = true + │ + └─► Broadcasting (ModelEvent only) + broadcast on private channel: models.{model_id} + broadcast on public channel: model + event name: modularity.{event_name} +``` + +→ [Broadcasting guide](/guide/broadcasting/overview) diff --git a/docs/src/pages/system-reference/backend/events/traits/event-changes.md b/docs/src/pages/system-reference/backend/events/traits/event-changes.md new file mode 100644 index 000000000..e33a3c8bf --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/traits/event-changes.md @@ -0,0 +1,81 @@ +--- +sidebarPos: 2 +sidebarTitle: EventChanges +--- + +# EventChanges + +`Unusualify\Modularity\Events\Traits\EventChanges` + +Captures which model attributes and relationships changed before the event fired. Added to every `ModelEvent` subclass automatically. + +## Source + +```php +trait EventChanges +{ + protected array $changedAttributes = []; + protected array $changedRelationships = []; + + public function setupEventChanges(): void + { + if ($this->model instanceof Model) { + $this->changedAttributes = $this->model->getChanges(); + $this->changedRelationships = method_exists($this->model, 'getChangedRelationships') + ? $this->model->getChangedRelationships() + : []; + } + } + + public function wasChanged($values = null): bool +} +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$changedAttributes` | `array` | Keyed array of dirty model attributes — same shape as `$model->getChanges()` | +| `$changedRelationships` | `array` | Keyed array of changed relationships — populated when the model implements `getChangedRelationships()` | + +Both properties are `protected`. Use `wasChanged()` to query them from outside the event. + +## Methods + +### `wasChanged($values = null): bool` + +| Signature | Returns | Description | +|-----------|---------|-------------| +| `wasChanged()` | `bool` | `true` if any attribute **or** relationship changed | +| `wasChanged('key')` | `bool` | `true` if `'key'` is present in either `$changedAttributes` or `$changedRelationships` | +| `wasChanged(['a', 'b'])` | `bool` | `true` if any of the listed keys changed | + +The method wraps the value argument with `Arr::wrap()`, so both a string and an array are accepted. + +## Behaviour Notes + +- `$changedAttributes` is populated from `$model->getChanges()`, which returns the values that were **just saved** — not the original values. It is only non-empty if the model was dirty at save time. +- `$changedRelationships` requires the model to implement a `getChangedRelationships()` method (e.g. from the `HasRepeaters` or `HasFiles` traits). If the method does not exist, the array is empty. +- If `$model` is not an Eloquent `Model` instance (e.g. a plain DTO), both arrays remain empty. + +## Example + +```php +public function handle(OrderUpdatedEvent $event): void +{ + // Check if anything at all changed + if (! $event->wasChanged()) { + return; + } + + // React to a specific attribute + if ($event->wasChanged('status')) { + NotifyCustomer::dispatch($event->model); + } + + // React to any of several fields + if ($event->wasChanged(['amount', 'currency'])) { + RecalculateTax::dispatch($event->model); + } +} +``` diff --git a/docs/src/pages/system-reference/backend/events/traits/event-stateable.md b/docs/src/pages/system-reference/backend/events/traits/event-stateable.md new file mode 100644 index 000000000..721e2dd11 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/traits/event-stateable.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 3 +sidebarTitle: EventStateable +--- + +# EventStateable + +`Unusualify\Modularity\Events\Traits\EventStateable` + +Captures state machine transition data when the model uses the `HasStateable` entity trait. Added to every `ModelEvent` subclass automatically. Properties are only meaningful when `$hasStateable` is `true`. + +## Source + +```php +trait EventStateable +{ + public bool $hasStateable = false; + public bool $stateableChanged = false; + public string|null $previousStateableState = null; + public string|null $currentStateableState = null; + + public function setupEventStateable(): void + { + if ($this->model instanceof Model && classHasTrait($this->model, HasStateable::class)) { + $this->hasStateable = true; + $this->stateableChanged = $this->model->stateableChanged(); + $this->previousStateableState = $this->model->previousStateableState(); + $this->currentStateableState = $this->model->currentStateableState(); + } + } +} +``` + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$hasStateable` | `bool` | `false` | `true` when the model uses `HasStateable` | +| `$stateableChanged` | `bool` | `false` | `true` when the model transitioned to a new state during this save | +| `$previousStateableState` | `string\|null` | `null` | State name before the transition | +| `$currentStateableState` | `string\|null` | `null` | State name after the transition | + +## Behaviour Notes + +- Setup is skipped entirely when the model does not use `HasStateable`. All properties stay at their defaults. +- `$stateableChanged` can be `false` even on a model that has `HasStateable` — when a save occurs but the state field did not change. +- State names are string identifiers as defined in the model's stateable configuration (e.g. `'draft'`, `'published'`, `'archived'`). + +## Example + +```php +public function handle(ArticleUpdatedEvent $event): void +{ + // Guard: only act if this model uses the state machine + if (! $event->hasStateable) { + return; + } + + // Guard: only act on actual transitions + if (! $event->stateableChanged) { + return; + } + + $from = $event->previousStateableState; // e.g. 'draft' + $to = $event->currentStateableState; // e.g. 'published' + + match ($to) { + 'published' => NotifySubscribers::dispatch($event->model), + 'archived' => CleanupDraftMedia::dispatch($event->model), + default => null, + }; +} +``` + +## Related + +- [`HasStateable`](/system-reference/backend/entity-traits/overview) entity trait — defines the state machine on the model side. diff --git a/docs/src/pages/system-reference/backend/events/traits/event-urls.md b/docs/src/pages/system-reference/backend/events/traits/event-urls.md new file mode 100644 index 000000000..d2f67b0d1 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/traits/event-urls.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 4 +sidebarTitle: EventUrls +--- + +# EventUrls + +`Unusualify\Modularity\Events\Traits\EventUrls` + +Captures the current and previous HTTP request URLs when the event is constructed. Added to every `ModelEvent` subclass automatically. + +## Source + +```php +trait EventUrls +{ + public string $recentUrl; + public string $previousUrl; + + public function setupEventUrls(): void + { + $this->recentUrl = url()->current() ?? null; + $this->previousUrl = url()->previous() ?? null; + } + + public function getRecentUrl(): string + public function getPreviousUrl(): string +} +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$recentUrl` | `string\|null` | The URL of the request that triggered the event (`url()->current()`) | +| `$previousUrl` | `string\|null` | The URL the user navigated from (`url()->previous()`) | + +## Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `getRecentUrl()` | `string\|null` | Returns `$recentUrl` | +| `getPreviousUrl()` | `string\|null` | Returns `$previousUrl` | + +## Behaviour Notes + +- Both values are `null` when the event fires outside an HTTP context (queued jobs, CLI commands). +- `url()->previous()` reads from the session; it may be `null` on the first request of a session. + +## Example + +```php +public function handle(SomeModelEvent $event): void +{ + $current = $event->getRecentUrl(); + $previous = $event->getPreviousUrl(); + + if ($current && $previous) { + logger("Navigated from {$previous} to {$current}"); + } +} +``` diff --git a/docs/src/pages/system-reference/backend/events/traits/event-user.md b/docs/src/pages/system-reference/backend/events/traits/event-user.md new file mode 100644 index 000000000..4d407ddf3 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/traits/event-user.md @@ -0,0 +1,59 @@ +--- +sidebarPos: 5 +sidebarTitle: EventUser +--- + +# EventUser + +`Unusualify\Modularity\Events\Traits\EventUser` + +Captures the currently authenticated user at the moment the event is constructed. Added to every `ModelEvent` subclass automatically. + +## Source + +```php +trait EventUser +{ + public $user; + + public function setupEventUser(): void + { + $this->user = Auth::user(); + } + + public function hasUser(): bool + public function getUser(): Model +} +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$user` | `Illuminate\Database\Eloquent\Model\|null` | The authenticated user, or `null` when the event fires in an unauthenticated context (queues, CLI) | + +## Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `hasUser()` | `bool` | `true` when `$user` is not `null` | +| `getUser()` | `Model\|null` | Returns the user model | + +## Behaviour Notes + +- `$user` is resolved via `Auth::user()` — it reflects the guard active at fire time. +- In queued jobs or console contexts `Auth::user()` returns `null`, so always guard with `hasUser()` before reading user data. + +## Example + +```php +public function handle(SomeModelEvent $event): void +{ + if (! $event->hasUser()) { + return; // fired from a queue or CLI — no auth context + } + + $actor = $event->getUser(); + activity()->causedBy($actor)->log('model updated'); +} +``` diff --git a/docs/src/pages/system-reference/backend/events/traits/overview.md b/docs/src/pages/system-reference/backend/events/traits/overview.md new file mode 100644 index 000000000..f3ab6c769 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/traits/overview.md @@ -0,0 +1,59 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Event Traits +--- + +# Event Traits + +Four traits are mixed into every `ModelEvent` subclass automatically. They populate contextual data — who triggered the event, what changed, where the request came from, and what state the model transitioned through — so listeners never have to repeat that boilerplate. + +## Trait Summary + +| Trait | File | What it captures | +|-------|------|-----------------| +| [EventUser](./event-user) | `Events/Traits/EventUser.php` | Authenticated user at fire time | +| [EventUrls](./event-urls) | `Events/Traits/EventUrls.php` | Current and previous HTTP URLs | +| [EventChanges](./event-changes) | `Events/Traits/EventChanges.php` | Dirty attributes and changed relationships | +| [EventStateable](./event-stateable) | `Events/Traits/EventStateable.php` | State machine transition details | + +## Setup Lifecycle + +`ModelEvent::__construct()` calls each trait's setup method in this order: + +``` +new SomeModelEvent($model) + │ + ├─ setupEventUser() → $this->user + ├─ setupEventUrls() → $this->recentUrl, $this->previousUrl + ├─ setupEventChanges() → $this->changedAttributes, $this->changedRelationships + └─ setupEventStateable() → $this->hasStateable, $this->stateableChanged, ... +``` + +All properties are set by the time any listener receives the event. + +## Using Traits in a Listener + +```php +public function handle(SomeModelEvent $event): void +{ + // EventUser + if ($event->hasUser()) { + $userId = $event->getUser()->id; + } + + // EventUrls + $from = $event->getPreviousUrl(); + $to = $event->getRecentUrl(); + + // EventChanges + if ($event->wasChanged('status')) { + // status attribute or relationship changed + } + + // EventStateable + if ($event->stateableChanged) { + $transition = $event->previousStateableState . ' → ' . $event->currentStateableState; + } +} +``` diff --git a/docs/src/pages/system-reference/backend/events/user-events.md b/docs/src/pages/system-reference/backend/events/user-events.md new file mode 100644 index 000000000..cdf9a0d79 --- /dev/null +++ b/docs/src/pages/system-reference/backend/events/user-events.md @@ -0,0 +1,200 @@ +--- +sidebarPos: 4 +sidebarTitle: User Events +--- + +# User Events + +Modularous fires four events during the user registration and verification flow. All four use `SerializesModels` so they are safe to queue. None extend `ModelEvent`; they are standalone event classes. + +## Registration Flow + +``` +HTTP POST /register + │ + ▼ + ModularityUserRegistering ← fired before user is created + │ + ▼ + [user record created] + │ + ▼ + ModularityUserRegistered ← fired after user is created + │ + ├─ standard flow ──► (done) + │ + └─ email-verified ──► VerifiedEmailRegister +``` + +Separately, when a user requests email verification: + +``` +POST /email/verify + │ + ▼ + ModularityUserVerification ← fired on verification request +``` + +--- + +## ModularityUserRegistering + +`Unusualify\Modularity\Events\ModularityUserRegistering` + +Fired just before a new user is persisted. Use this event to validate or enrich the registration request before the record is written. + +### Constructor + +```php +public function __construct(public $request, bool $isOauth = false) +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$request` | `Illuminate\Http\Request` | The incoming registration request | + +### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `isOauth()` | `bool` | `true` when registration comes from an OAuth provider | + +### Example Listener + +```php +public function handle(ModularityUserRegistering $event): void +{ + if ($event->isOauth()) { + // OAuth pre-registration logic + } + + // Access request data + $email = $event->request->input('email'); +} +``` + +--- + +## ModularityUserRegistered + +`Unusualify\Modularity\Events\ModularityUserRegistered` + +Fired immediately after the user record is created. Use this event to send welcome emails, assign default roles, create related records, etc. + +### Constructor + +```php +public function __construct($user, Request $request, bool $isOauth = false) +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$user` | `Illuminate\Contracts\Auth\Authenticatable` | The newly created user | +| `$request` | `Illuminate\Http\Request` | The registration request | + +### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `isOauth()` | `bool` | `true` when the user registered via OAuth | + +### Example Listener + +```php +public function handle(ModularityUserRegistered $event): void +{ + $user = $event->user; + + if ($event->isOauth()) { + // Skip verification email for OAuth users + return; + } + + // Send welcome notification + $user->notify(new WelcomeNotification()); +} +``` + +--- + +## ModularityUserVerification + +`Unusualify\Modularity\Events\ModularityUserVerification` + +Fired when a user initiates email verification. Use this event to log verification attempts or trigger secondary verification flows. + +### Constructor + +```php +public function __construct(public $request) +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$request` | `Illuminate\Http\Request` | The verification request | + +### Example Listener + +```php +public function handle(ModularityUserVerification $event): void +{ + // Log or audit the verification attempt + logger('Verification initiated from IP: ' . $event->request->ip()); +} +``` + +--- + +## VerifiedEmailRegister + +`Unusualify\Modularity\Events\VerifiedEmailRegister` + +Fired after a user completes the verified-email registration path (i.e., the user confirmed ownership of their email address during sign-up). + +### Constructor + +```php +public function __construct($user) +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$user` | `Illuminate\Contracts\Auth\Authenticatable` | The user who completed verified registration | + +### Example Listener + +```php +public function handle(VerifiedEmailRegister $event): void +{ + $event->user->markEmailAsVerified(); + // Finalize account setup +} +``` + +--- + +## Registering Listeners + +Wire up listeners in your module's `EventServiceProvider` (or the application's `App\Providers\EventServiceProvider`): + +```php +use Unusualify\Modularity\Events\ModularityUserRegistering; +use Unusualify\Modularity\Events\ModularityUserRegistered; +use Unusualify\Modularity\Events\ModularityUserVerification; +use Unusualify\Modularity\Events\VerifiedEmailRegister; + +protected $listen = [ + ModularityUserRegistering::class => [YourPreRegisterListener::class], + ModularityUserRegistered::class => [YourPostRegisterListener::class], + ModularityUserVerification::class => [YourVerificationListener::class], + VerifiedEmailRegister::class => [YourVerifiedRegisterListener::class], +]; +``` diff --git a/docs/src/pages/system-reference/backend/facades/coverage.md b/docs/src/pages/system-reference/backend/facades/coverage.md new file mode 100644 index 000000000..9355b81e8 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/coverage.md @@ -0,0 +1,70 @@ +--- +sidebarPos: 2 +sidebarTitle: Coverage +--- + +# Coverage + +**Facade**: `Unusualify\Modularity\Facades\Coverage` +**Accessor**: `coverage.service` +**Underlying**: `Unusualify\Modularity\Services\CoverageService` + +Parses PHPUnit Clover XML coverage reports and provides analysis, filtering, and export utilities. See [CoverageService](/system-reference/backend/services/coverage-service) for implementation details. + +## Methods + +### Configuration (Fluent) + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setCloverPath` | `(string $path): CoverageService` | Set the path to the Clover XML file | +| `filterByFiles` | `(array $files): CoverageService` | Limit analysis to specific files | +| `setCoverageThreshold` | `(float $threshold): CoverageService` | Set the minimum coverage percentage | +| `skipMagicMethods` | `(bool $skip = true): CoverageService` | Exclude `__*` methods from analysis | +| `skipPrivateMethods` | `(bool $skip = true): CoverageService` | Exclude private methods | +| `skipProtectedMethods` | `(bool $skip = true): CoverageService` | Exclude protected methods | + +### Analysis + +| Method | Signature | Description | +|--------|-----------|-------------| +| `analyze` | `(): array` | Full coverage analysis of all files | +| `analyzeFile` | `(string $filePath): array` | Analysis for a single file | +| `getMethodCoverage` | `(string $filePath, string $methodName): array\|null` | Coverage data for a specific method | +| `uncovered` | `(array $files = []): array` | Returns methods with 0% coverage | +| `partial` | `(float $threshold = 50.0, array $files = []): array` | Returns methods below `$threshold`% | +| `stats` | `(?array $files = null): array` | Returns aggregate coverage statistics | +| `git` | `(string $baseBranch = 'main'): array` | Returns coverage for files changed vs `$baseBranch` | +| `checkPR` | `(string $baseBranch = 'main', bool $throwOnFailure = false): bool` | CI check — returns false (or throws) if coverage drops | + +### Export + +| Method | Signature | Description | +|--------|-----------|-------------| +| `json` | `(?array $files = null, bool $prettyPrint = true): string` | Returns coverage as JSON | +| `markdown` | `(?array $files = null): string` | Returns coverage as a Markdown table | +| `html` | `(?array $files = null): string` | Returns coverage as an HTML report | +| `save` | `(string $outputPath, ?array $files = null, string $format = 'json'): bool` | Saves the report to a file | + +### Error Handling + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getErrors` | `(): array` | Returns any parse errors encountered | +| `hasErrors` | `(): bool` | Whether any errors occurred | + +## Usage + +```php +use Unusualify\Modularity\Facades\Coverage; + +$report = Coverage::setCloverPath(storage_path('coverage.xml')) + ->skipMagicMethods() + ->setCoverageThreshold(80.0) + ->analyze(); + +// In CI +if (!Coverage::checkPR('main', throwOnFailure: true)) { + exit(1); +} +``` diff --git a/docs/src/pages/system-reference/backend/facades/currency-exchange.md b/docs/src/pages/system-reference/backend/facades/currency-exchange.md new file mode 100644 index 000000000..e2da1df0f --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/currency-exchange.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 3 +sidebarTitle: CurrencyExchange +--- + +# CurrencyExchange + +**Facade**: `Unusualify\Modularity\Facades\CurrencyExchange` +**Accessor**: `currency.exchange` +**Underlying**: `Unusualify\Modularity\Services\CurrencyExchangeService` + +Fetches live exchange rates from an external provider and converts amounts between currencies. See [CurrencyExchangeService](/system-reference/backend/services/currency-exchange-service) for implementation details. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `fetchExchangeRates` | `(): array` | Fetches and returns current exchange rates from the configured provider | +| `convertTo` | `(float $amount, string $targetCurrency): float` | Converts `$amount` from the base currency to `$targetCurrency` | +| `getExchangeRate` | `(string $currency): float` | Returns the exchange rate for a single currency code | + +## Usage + +```php +use Unusualify\Modularity\Facades\CurrencyExchange; + +$rate = CurrencyExchange::getExchangeRate('EUR'); + +$amountInEur = CurrencyExchange::convertTo(100.00, 'EUR'); +``` + +## Notes + +- Exchange rates are cached to avoid repeated external HTTP requests. +- The base currency is configured via `modularity.currency.base` (default: `USD`). diff --git a/docs/src/pages/system-reference/backend/facades/filepond.md b/docs/src/pages/system-reference/backend/facades/filepond.md new file mode 100644 index 000000000..3e2839fa2 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/filepond.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 4 +sidebarTitle: Filepond +--- + +# Filepond + +**Facade**: `Unusualify\Modularity\Facades\Filepond` +**Accessor**: `Filepond` +**Underlying**: `Unusualify\Modularity\Services\FilepondManager` + +Provides the server-side handling for FilePond file uploads — processing temporary uploads, generating signed URLs, and managing the temporary file lifecycle. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `process` | `(mixed $file): mixed` | Handles a FilePond `process` request; stores the file temporarily and returns the server ID | +| `validate` | `(mixed $file): bool` | Validates an uploaded file against configured rules | +| `generateTemporaryUrl` | `(string $path): string` | Generates a signed temporary URL for a stored file | +| `delete` | `(string $path): bool` | Removes a temporary file by its server path | +| `getServerConfig` | `(): array` | Returns the FilePond server config array for the frontend | + +## Usage + +```php +use Unusualify\Modularity\Facades\Filepond; + +// In a FilePond process endpoint +public function process(Request $request) +{ + return Filepond::process($request->file('filepond')); +} + +// Get server config to pass to the Vue component +$serverConfig = Filepond::getServerConfig(); +``` + +## Notes + +- The `getServerConfig()` return value is injected into the Inertia page props and consumed by the `ue-filepond` Vue component to configure upload endpoints automatically. +- Temporary files are stored in the disk defined by `modularity.filepond.disk` config (default: `local`). +- `flush:filepond` artisan command clears abandoned temporary files. diff --git a/docs/src/pages/system-reference/backend/facades/host-routing-registrar.md b/docs/src/pages/system-reference/backend/facades/host-routing-registrar.md new file mode 100644 index 000000000..3f14215ab --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/host-routing-registrar.md @@ -0,0 +1,37 @@ +--- +sidebarPos: 6 +sidebarTitle: HostRoutingRegistrar +--- + +# HostRoutingRegistrar + +**Facade**: `Unusualify\Modularity\Facades\HostRoutingRegistrar` +**Accessor**: `unusualify.hostRouting` +**Underlying**: `Unusualify\Modularity\Support\HostRouting` + +Manages the registration of host-based routes — adding, removing, and clearing host → action mappings. Companion to `HostRouting` (which handles introspection of the current request). + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `registerRoutes` | `(): void` | Registers all configured host routes with the Laravel router | +| `addRoute` | `(string $host, string $action): void` | Adds a host → action mapping | +| `removeRoute` | `(string $host): void` | Removes a host mapping | +| `getRegisteredRoutes` | `(): array` | Returns all currently registered host routes | +| `clearRegisteredRoutes` | `(): void` | Removes all registered host routes | + +## Usage + +```php +use Unusualify\Modularity\Facades\HostRoutingRegistrar; + +// In a service provider +HostRoutingRegistrar::addRoute('tenant1.app.test', TenantController::class); +HostRoutingRegistrar::registerRoutes(); +``` + +## Notes + +- Both `HostRouting` and `HostRoutingRegistrar` resolve to the same underlying `HostRouting` support class but are bound under different container keys (`unusualify.hosting` vs `unusualify.hostRouting`). +- `registerRoutes()` should be called in a service provider's `boot()` method after all host routes have been added. diff --git a/docs/src/pages/system-reference/backend/facades/host-routing.md b/docs/src/pages/system-reference/backend/facades/host-routing.md new file mode 100644 index 000000000..8af8324ec --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/host-routing.md @@ -0,0 +1,32 @@ +--- +sidebarPos: 5 +sidebarTitle: HostRouting +--- + +# HostRouting + +**Facade**: `Unusualify\Modularity\Facades\HostRouting` +**Accessor**: `unusualify.hosting` +**Underlying**: `Unusualify\Modularity\Support\HostRouting` + +Provides host-based (subdomain / domain) routing introspection and management. Works alongside `HostableMiddleware` for multi-tenant subdomain scenarios. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getHost` | `(): string` | Returns the full current host (e.g. `tenant.app.test`) | +| `getSubdomain` | `(): string` | Returns the subdomain portion | +| `getDomain` | `(): string` | Returns the root domain | +| `getTld` | `(): string` | Returns the TLD | +| `isSubdomain` | `(): bool` | Whether the current request is on a subdomain | +| `isDomain` | `(): bool` | Whether the current request is on the root domain | +| `isTld` | `(): bool` | Whether the current request matches the TLD | +| `getRoutes` | `(): array` | Returns all registered host routes | +| `addRoute` | `(string $host, string $action): void` | Registers a host → action mapping | +| `removeRoute` | `(string $host): void` | Removes a host route mapping | + +## Notes + +- Used in conjunction with `HostRoutingRegistrar` — `HostRouting` is for introspection and resolving the current host, while `HostRoutingRegistrar` handles registration. +- Pair with `HostableMiddleware` on routes that should be restricted to specific hosts. diff --git a/docs/src/pages/system-reference/backend/facades/migration-backup.md b/docs/src/pages/system-reference/backend/facades/migration-backup.md new file mode 100644 index 000000000..396338d75 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/migration-backup.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 7 +sidebarTitle: MigrationBackup +--- + +# MigrationBackup + +**Facade**: `Unusualify\Modularity\Facades\MigrationBackup` +**Accessor**: `migration.backup` +**Underlying**: `Unusualify\Modularity\Services\MigrationBackupService` + +Temporarily backs up and restores table data around destructive migration operations (e.g. dropping and recreating a table). Stores the backup in the cache to survive the migration run. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `backup` | `(string $table, ?array $columns = null): void` | Reads the table rows (optionally filtered to `$columns`) and saves them to cache | +| `restore` | `(): bool` | Inserts the backed-up rows back into the table; returns `true` on success | +| `getBackup` | `(): array\|null` | Returns the raw backed-up data, or `null` if no backup exists | +| `clearBackup` | `(): void` | Removes the backup from cache | +| `getBackupKey` | `(): string` | Returns the cache key used to store the backup | + +## Usage + +```php +use Unusualify\Modularity\Facades\MigrationBackup; + +// In a migration +public function up() +{ + MigrationBackup::backup('settings'); + + Schema::drop('settings'); + Schema::create('settings', function (Blueprint $table) { + // new schema + }); + + MigrationBackup::restore(); +} +``` + +## Notes + +- Intended for migrations that must drop and recreate a table while preserving existing data. +- The backup TTL is long enough to survive a typical migration run but does not persist permanently — call `clearBackup()` if the restore is not needed. diff --git a/docs/src/pages/system-reference/backend/facades/modularity-cache.md b/docs/src/pages/system-reference/backend/facades/modularity-cache.md new file mode 100644 index 000000000..ba3a79dd7 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/modularity-cache.md @@ -0,0 +1,86 @@ +--- +sidebarPos: 9 +sidebarTitle: ModularityCache +--- + +# ModularityCache + +**Facade**: `Unusualify\Modularity\Facades\ModularityCache` +**Accessor**: `modularity.cache` +**Underlying**: `Unusualify\Modularity\Services\ModularityCacheService` + +Full cache facade for Modularous — handles tag-based caching, TTL management, and targeted cache invalidation scoped to modules and route names. See [ModularityCacheService](/system-reference/backend/services/modularity-cache-service) for implementation details. + +## Methods + +### Configuration + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getDriver` | `(): string` | Returns the active cache driver name | +| `getPrefix` | `(): string` | Returns the cache key prefix | +| `isEnabled` | `(?string $module = null): bool` | Whether caching is enabled globally or for a module | +| `usesTags` | `(): bool` | Whether the driver supports cache tags | +| `getTtl` | `(string $type, ?string $module = null): int` | Returns TTL in seconds for the given type | + +### Key Generation + +| Method | Signature | Description | +|--------|-----------|-------------| +| `generateCacheKey` | `(string $module, string $routeName, string $type, array $params = []): string` | Builds a fully-qualified cache key | +| `generateRecordKey` | `(string $module, string $routeName, $id): string` | Builds a key for a single model record | +| `getModuleTags` | `(string $module): array` | Tags for an entire module | +| `getRouteTags` | `(string $module, string $routeName): array` | Tags for a specific module route | +| `getTypeTags` | `(string $module, string $routeName, string $type): array` | Tags for a type within a route | +| `generateRelationTag` | `(string $modelClass, $id): string` | Tag for a single model instance | +| `generateRelationTags` | `(array $relations): array` | Tags for multiple model instances | + +### Read / Write + +| Method | Signature | Description | +|--------|-----------|-------------| +| `remember` | `(string $key, int $ttl, Closure $callback, ...): mixed` | Cache or compute a value with TTL | +| `rememberForever` | `(string $key, Closure $callback, ...): mixed` | Cache a value without expiry | +| `rememberWithRelations` | `(string $key, int $ttl, Closure $callback, ..., array $relations): mixed` | Cache with relation tags | +| `get` | `(string $key, $default = null, ...): mixed` | Retrieve a cached value | +| `put` | `(string $key, $value, int $ttl, ...): bool` | Store a value with TTL | +| `putWithRelations` | `(string $key, $value, int $ttl, ..., array $relations): bool` | Store with relation tags | +| `has` | `(string $key, ...): bool` | Check if a key exists | +| `forget` | `(string $key, ...): bool` | Delete a specific key | +| `flush` | `(): bool` | Flush all Modularous cache entries | + +### Invalidation + +| Method | Signature | Description | +|--------|-----------|-------------| +| `invalidateModule` | `(string $module): bool` | Invalidate all cache for a module | +| `invalidateModuleRoute` | `(string $module, string $routeName): bool` | Invalidate all cache for a route | +| `invalidateByRelatedModel` | `(string $modelClass, $id): bool` | Invalidate by a single related model | +| `invalidateByRelatedModels` | `(array $relations): int` | Invalidate by multiple related models | +| `invalidateByPattern` | `(string $pattern): int` | Invalidate by key pattern | +| `invalidateForModel` | `(Model $model): void` | Invalidate all cache entries for a model instance | +| `invalidateCountCaches` | `(string $module, string $routeName): void` | Invalidate count caches for a route | +| `invalidateIndexCaches` | `(string $module, string $routeName): void` | Invalidate index caches for a route | + +### Stats + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getStats` | `(?string $module = null): array` | Returns cache statistics | + +## Usage + +```php +use Unusualify\Modularity\Facades\ModularityCache; + +$items = ModularityCache::remember( + ModularityCache::generateCacheKey('Blog', 'posts', 'index'), + ModularityCache::getTtl('index', 'Blog'), + fn() => $repository->getIndexItems(), + 'Blog', + 'posts' +); + +// Invalidate after a save +ModularityCache::invalidateModuleRoute('Blog', 'posts'); +``` diff --git a/docs/src/pages/system-reference/backend/facades/modularity-finder.md b/docs/src/pages/system-reference/backend/facades/modularity-finder.md new file mode 100644 index 000000000..ae3ef47b9 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/modularity-finder.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 10 +sidebarTitle: ModularityFinder +--- + +# ModularityFinder + +**Facade**: `Unusualify\Modularity\Facades\ModularityFinder` +**Accessor**: `Unusualify\Modularity\Support\Finder::class` +**Underlying**: `Unusualify\Modularity\Support\Finder` + +Resolves model class names, repository class names, and related metadata from route names or database table names. Used internally by the cache system, middleware, and console commands to dynamically discover module components. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getModel` | `(string $table): string\|false` | Returns the model FQCN for a given table name | +| `getRouteModel` | `(string $routeName, bool $asClass = false): string\|false` | Returns the model for a route name | +| `getRepository` | `(string $table): string\|false` | Returns the repository FQCN for a given table name | +| `getRouteRepository` | `(string $routeName, bool $asClass = false): string\|false` | Returns the repository for a route name | +| `getPossibleModels` | `(string $routeName): array` | Returns all candidate model classes for a route | +| `getClasses` | `(string $path): array` | Returns all PHP class names found under a directory path | +| `getAllModels` | `(): Collection` | Returns all registered model classes across all modules | + +## Usage + +```php +use Unusualify\Modularity\Facades\ModularityFinder; + +// Resolve a model from a table name +$modelClass = ModularityFinder::getModel('blog_posts'); +// → 'Modules\Blog\Entities\Post' + +// Resolve a repository from a route name +$repoClass = ModularityFinder::getRouteRepository('blog.posts'); +// → 'Modules\Blog\Repositories\PostRepository' + +// Get all models across all modules +$allModels = ModularityFinder::getAllModels(); +``` + +## Notes + +- `UFinder` is a deprecated alias for this facade. Use `ModularityFinder` in new code. +- Resolves using a combination of module config, naming conventions, and class-map scanning. diff --git a/docs/src/pages/system-reference/backend/facades/modularity-log.md b/docs/src/pages/system-reference/backend/facades/modularity-log.md new file mode 100644 index 000000000..f721cd95e --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/modularity-log.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 11 +sidebarTitle: ModularityLog +--- + +# ModularityLog + +**Facade**: `Unusualify\Modularity\Facades\ModularityLog` +**Accessor**: `modularity.log` +**Underlying**: `Illuminate\Log\LogManager` (dedicated `modularity` channel) + +A dedicated logging facade that writes to the Modularous log channel instead of the default application log. Exposes the same interface as Laravel's built-in `Log` facade. + +## Methods + +All standard PSR-3 log levels are available: + +| Method | Description | +|--------|-------------| +| `emergency(string $message, array $context = [])` | System is unusable | +| `alert(string $message, array $context = [])` | Action must be taken immediately | +| `critical(string $message, array $context = [])` | Critical conditions | +| `error(string $message, array $context = [])` | Runtime errors | +| `warning(string $message, array $context = [])` | Exceptional occurrences that are not errors | +| `notice(string $message, array $context = [])` | Normal but significant events | +| `info(string $message, array $context = [])` | Informational messages | +| `debug(string $message, array $context = [])` | Detailed debug information | +| `log(string $level, string $message, array $context = [])` | Log with arbitrary level | + +## Usage + +```php +use Unusualify\Modularity\Facades\ModularityLog; + +ModularityLog::info('Module booted', ['module' => 'Blog']); + +ModularityLog::error('Cache write failed', [ + 'key' => $cacheKey, + 'exception' => $e->getMessage(), +]); +``` + +## Notes + +- Writes to the `modularity` log channel defined in `config/logging.php`. +- Use this instead of `Log::` for internal Modularous events to keep application logs clean. +- The `modularity.log` middleware also uses this channel to log each incoming request. diff --git a/docs/src/pages/system-reference/backend/facades/modularity-routes.md b/docs/src/pages/system-reference/backend/facades/modularity-routes.md new file mode 100644 index 000000000..ab4ad7e5e --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/modularity-routes.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 12 +sidebarTitle: ModularityRoutes +--- + +# ModularityRoutes + +**Facade**: `Unusualify\Modularity\Facades\ModularityRoutes` +**Accessor**: `Unusualify\Modularity\Support\ModularityRoutes::class` +**Underlying**: `Unusualify\Modularity\Support\ModularityRoutes` + +Handles route middleware registration and provides the standard middleware stacks for web and API routes. Used in `BaseServiceProvider` to register all Modularous middleware aliases and groups. + +## Key Responsibilities + +- Registers all `modularity.*` middleware aliases via `generateRouteMiddlewares()` +- Defines the `modularity.core`, `modularity.panel`, `web.auth`, and `api.auth` middleware groups +- Provides helper methods that return pre-built middleware arrays for each route type + +## Middleware Stacks + +| Method | Stack | +|--------|-------| +| `webMiddlewares()` | `modularity.log` + `modularity.core` | +| `webPanelMiddlewares()` | `modularity.log` + `modularity.core` + `modularity.panel` | +| `apiMiddlewares()` | `modularity.log` + `api.auth` | +| `apiPanelMiddlewares()` | `modularity.log` + `api.auth` + `modularity.panel` | + +## Usage + +```php +use Unusualify\Modularity\Facades\ModularityRoutes; + +// In a route file +Route::middleware(ModularityRoutes::webPanelMiddlewares()) + ->prefix(adminUrlPrefix()) + ->group(function () { + // panel routes + }); +``` + +## Notes + +- `generateRouteMiddlewares()` is called automatically during `BaseServiceProvider::boot()`. You do not need to call it manually. +- All 14 Modularous middleware classes are wired to their aliases here. See [Middleware](/system-reference/backend/http/middleware/overview) for full details. diff --git a/docs/src/pages/system-reference/backend/facades/modularity-vite.md b/docs/src/pages/system-reference/backend/facades/modularity-vite.md new file mode 100644 index 000000000..6c051ece7 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/modularity-vite.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 13 +sidebarTitle: ModularityVite +--- + +# ModularityVite + +**Facade**: `Unusualify\Modularity\Facades\ModularityVite` +**Accessor**: `Unusualify\Modularity\Support\ModularityVite::class` +**Underlying**: `Unusualify\Modularity\Support\ModularityVite` + +Modularous's Vite integration layer. Works like Laravel's built-in `Vite` facade but resolves assets from the Modularous package's build directory rather than the host application's `public/build`. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `__invoke` | `(string\|string[] $entrypoints, string\|null $buildDirectory = null): HtmlString` | Generates `<script>` and `<link>` tags for entrypoints | +| `hotAsset` | `(string $asset): string` | Returns the HMR dev server URL for an asset | +| `isRunningHot` | `(): bool` | Returns `true` if Vite dev server is active | +| `makeTagForChunk` | `(string $src, string $url, array\|null $chunk, array\|null $manifest): string` | Generates an HTML tag for a manifest chunk | +| `makePreloadTagForChunk` | `(string $src, string $url, array $chunk, array $manifest): string` | Generates a `<link rel="modulepreload">` tag | +| `chunk` | `(array $manifest, string $file): array` | Returns the manifest entry for a file | +| `manifest` | `(string $buildDirectory): array` | Returns the parsed Vite manifest JSON | +| `assetPath` | `(string $path): string` | Returns the public path for a built asset | +| `isCssPath` | `(string $path): bool` | Returns `true` if the path points to a CSS file | + +## Usage + +In Blade layouts: + +```php +{!! ModularityVite::__invoke(['resources/js/app.js', 'resources/css/app.css']) !!} +``` + +Or via the `@modularityVite` Blade directive registered by the package: + +```blade +@modularityVite(['resources/js/app.js']) +``` + +## Notes + +- During development (`isRunningHot() === true`) assets are served from the Vite HMR server. +- In production, the manifest is read from the Modularous package's `public/build/manifest.json`. diff --git a/docs/src/pages/system-reference/backend/facades/modularity.md b/docs/src/pages/system-reference/backend/facades/modularity.md new file mode 100644 index 000000000..7b391fba5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/modularity.md @@ -0,0 +1,53 @@ +--- +sidebarPos: 8 +sidebarTitle: Modularity +--- + +# Modularity + +**Facade**: `Unusualify\Modularity\Facades\Modularity` +**Accessor**: `modularity` +**Underlying**: `Unusualify\Modularity\Modularity` + +The primary facade for interacting with the Modularous module registry. Used throughout controllers, service providers, and helpers to resolve modules, check existence, and manage module state. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getModules` | `(): array` | Returns all registered modules | +| `getEnabledModules` | `(): array` | Returns only enabled modules | +| `hasModule` | `(string $name): bool` | Checks if a module is registered | +| `find` | `(string $name): Module` | Finds a module by name, returns `null` if not found | +| `findOrFail` | `(string $name): Module` | Finds a module, throws if not found | +| `getModulePath` | `(string $moduleName): string` | Returns the filesystem path to a module | +| `assetPath` | `(string $module): string` | Returns the public asset path for a module | +| `moduleAsset` | `(string $module, string $asset): string` | Returns the full URL for a module asset | +| `enableModule` | `(string $moduleName): void` | Enables a module | +| `disableModule` | `(string $moduleName): void` | Disables a module | +| `deleteModule` | `(string $moduleName): void` | Removes a module | + +## Usage + +```php +use Unusualify\Modularity\Facades\Modularity; + +// Find a module and call methods on it +$module = Modularity::find('Blog'); +$routeUrl = $module->getRouteActionUrl('posts', 'index'); + +// Check availability +if (Modularity::hasModule('Payment')) { + // payment features available +} + +// Iterate all enabled modules +foreach (Modularity::getEnabledModules() as $module) { + echo $module->getName(); +} +``` + +## Notes + +- `find()` is the most-used method across the codebase, also available as the global `curtModule()` helper. +- Module instances returned are `Nwidart\Modules\Module` objects extended by Modularous. diff --git a/docs/src/pages/system-reference/backend/facades/navigation.md b/docs/src/pages/system-reference/backend/facades/navigation.md new file mode 100644 index 000000000..0e1a52a52 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/navigation.md @@ -0,0 +1,28 @@ +--- +sidebarPos: 14 +sidebarTitle: Navigation +--- + +# Navigation + +**Facade**: `Unusualify\Modularity\Facades\Navigation` +**Accessor**: `modularity.navigation` +**Underlying**: `Unusualify\Modularity\Services\View\ModularityNavigation` + +Builds and formats the navigation data (sidebar, profile menu, bottom navigation) shared with Inertia pages. See [ModularityNavigation](/system-reference/backend/services/view/modularity-navigation) for implementation details. + +## Usage + +```php +use Unusualify\Modularity\Facades\Navigation; + +// Format a raw navigation config array for the sidebar +$sidebar = Navigation::formatSidebarMenu(config('modularity.navigation.sidebar.default')); +``` + +The `get_modularity_navigation_config()` helper in `sources.php` uses this facade internally to assemble the full navigation object shared with every Inertia page. + +## Notes + +- Navigation items are role-scoped: `default`, `superadmin`, `client`, and `guest` configs are looked up per authenticated user role. +- Items are resolved through `Navigation::formatSidebarMenu()` which handles route resolution, permission checks, and icon mapping. diff --git a/docs/src/pages/system-reference/backend/facades/overview.md b/docs/src/pages/system-reference/backend/facades/overview.md new file mode 100644 index 000000000..886ad4731 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/overview.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Facades + +Modularous registers 18 Laravel facades, all under the `Unusualify\Modularity\Facades\` namespace. Each facade is an alias to a bound service container entry, providing static-style access to the underlying service class. + +## Overview + +| Facade | Accessor | Underlying Service | +|--------|----------|--------------------| +| [Modularity](./modularity) | `modularity` | `Unusualify\Modularity\Modularity` | +| [ModularityCache](./modularity-cache) | `modularity.cache` | `ModularityCacheService` | +| [ModularityFinder](./modularity-finder) | `Finder::class` | `Support\Finder` | +| [ModularityLog](./modularity-log) | `modularity.log` | `Illuminate\Log\LogManager` (dedicated channel) | +| [ModularityRoutes](./modularity-routes) | `Support\ModularityRoutes::class` | `Support\ModularityRoutes` | +| [ModularityVite](./modularity-vite) | `Support\ModularityVite::class` | `Support\ModularityVite` | +| [Navigation](./navigation) | `modularity.navigation` | `ModularityNavigation` | +| [Redirect](./redirect) | `modularity.redirect` | `RedirectService` | +| [Utm](./utm) | `modularity.utm` | `UtmParameters` | +| [Filepond](./filepond) | `Filepond` | `Services\Filepond` / `FilepondManager` | +| [Coverage](./coverage) | `coverage.service` | `CoverageService` | +| [CurrencyExchange](./currency-exchange) | `currency.exchange` | `CurrencyExchangeService` | +| [MigrationBackup](./migration-backup) | `migration.backup` | `MigrationBackupService` | +| [RelationshipGraph](./relationship-graph) | `modularity.relationship.graph` | `CacheRelationshipGraph` | +| [Register](./register) | `auth.register` | `RegisterBrokerManager` | +| [HostRouting](./host-routing) | `unusualify.hosting` | `Support\HostRouting` | +| [HostRoutingRegistrar](./host-routing-registrar) | `unusualify.hostRouting` | `Support\HostRouting` | +| [UFinder](./u-finder) *(deprecated)* | `Finder::class` | `Support\Finder` | + +## Usage Pattern + +All Modularous facades follow standard Laravel facade usage: + +```php +use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Facades\ModularityCache; + +// Resolve a module +$module = Modularity::find('Blog'); + +// Cache a result scoped to a module route +$items = ModularityCache::remember($key, 3600, fn() => $repo->all(), 'Blog', 'posts'); +``` + +Facades are registered in `BaseServiceProvider` and are available everywhere in the Laravel application after the service provider boots. diff --git a/docs/src/pages/system-reference/backend/facades/redirect.md b/docs/src/pages/system-reference/backend/facades/redirect.md new file mode 100644 index 000000000..019d7c046 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/redirect.md @@ -0,0 +1,29 @@ +--- +sidebarPos: 15 +sidebarTitle: Redirect +--- + +# Redirect + +**Facade**: `Unusualify\Modularity\Facades\Redirect` +**Accessor**: `modularity.redirect` +**Underlying**: `Unusualify\Modularity\Services\RedirectService` + +Provides smart redirect logic for panel routes — resolves where to send users after login, after an action, or when a previous route is unavailable. See [RedirectService](/system-reference/backend/services/redirect-service) for implementation details. + +## Usage + +```php +use Unusualify\Modularity\Facades\Redirect; + +// Get the intended redirect target for the current user +$response = Redirect::intended(); + +// Redirect back to the previous panel route, falling back to a default +return Redirect::toPrevious('admin.dashboard'); +``` + +## Notes + +- Used by `RedirectorMiddleware` to handle post-login and post-action redirects. +- Integrates with `ManagePrevious` controller trait to store and restore previous route state across requests. diff --git a/docs/src/pages/system-reference/backend/facades/register.md b/docs/src/pages/system-reference/backend/facades/register.md new file mode 100644 index 000000000..b282d990c --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/register.md @@ -0,0 +1,58 @@ +--- +sidebarPos: 16 +sidebarTitle: Register +--- + +# Register + +**Facade**: `Unusualify\Modularity\Facades\Register` +**Accessor**: `auth.register` +**Underlying**: `Unusualify\Modularity\Brokers\RegisterBrokerManager` + +Extends Laravel's `Password` facade to provide an email-verification-based registration flow. Works like `Password::broker()` but for the registration verification pipeline. + +## Constants + +| Constant | Value description | +|----------|-------------------| +| `VERIFIED_EMAIL_REGISTER` | Registration completed successfully after email verification | +| `VERIFICATION_LINK_SENT` | Verification email sent — user must click the link | +| `ALREADY_REGISTERED` | The email address is already registered | +| `INVALID_VERIFICATION_TOKEN` | The verification token is invalid or expired | +| `VERIFICATION_THROTTLED` | Too many verification attempts — throttled | + +## Methods + +Inherits the full `Password` facade interface plus: + +| Method | Signature | Description | +|--------|-----------|-------------| +| `broker` | `(string\|null $name = null): PasswordBroker` | Resolves the registration broker | +| `sendResetLink` | `(array $credentials, ?Closure $callback = null): string` | Sends a verification/registration link | +| `reset` | `(array $credentials, Closure $callback): mixed` | Completes registration with a valid token | +| `getUser` | `(array $credentials): CanResetPassword\|null` | Resolves the user from credentials | +| `createToken` | `(CanResetPassword $user): string` | Creates a verification token | +| `deleteToken` | `(CanResetPassword $user): void` | Deletes a user's verification token | +| `tokenExists` | `(CanResetPassword $user, string $token): bool` | Checks if a token is valid | + +## Usage + +```php +use Unusualify\Modularity\Facades\Register; + +$status = Register::sendResetLink(['email' => $request->email]); + +if ($status === Register::VERIFICATION_LINK_SENT) { + return back()->with('status', 'Verification email sent.'); +} + +if ($status === Register::ALREADY_REGISTERED) { + return back()->withErrors(['email' => 'This email is already registered.']); +} +``` + +## Notes + +- Backed by `RegisterBroker` / `RegisterBrokerManager`, which follow the same interface as Laravel's password broker. +- The `auth.register` container binding is registered by `BaseServiceProvider`. +- See [Brokers →](/system-reference/backend/brokers/overview) for broker-layer internals. diff --git a/docs/src/pages/system-reference/backend/facades/relationship-graph.md b/docs/src/pages/system-reference/backend/facades/relationship-graph.md new file mode 100644 index 000000000..64ffe0b05 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/relationship-graph.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 17 +sidebarTitle: RelationshipGraph +--- + +# RelationshipGraph + +**Facade**: `Unusualify\Modularity\Facades\RelationshipGraph` +**Accessor**: `modularity.relationship.graph` +**Underlying**: `Unusualify\Modularity\Services\CacheRelationshipGraph` + +Builds and queries the model → module-route dependency graph used by `ModularityCache` to invalidate the right cache entries when a related model changes. See [CacheRelationshipGraph](/system-reference/backend/services/cache-relationship-graph) for implementation details. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `isEnabled` | `(): bool` | Whether relationship graph tracking is enabled | +| `getGraph` | `(): array` | Returns the full cached graph | +| `buildGraph` | `(): array` | Builds the graph and stores it in cache | +| `rebuildGraph` | `(): array` | Forces a fresh rebuild, ignoring the cached version | +| `clearGraph` | `(): void` | Removes the graph from cache | +| `isCached` | `(): bool` | Whether the graph is currently cached | +| `getAffectedSubmodules` | `(string $modelClass): array` | Returns module routes affected by a model class | +| `getAffectedSubmodulesByTable` | `(string $tableName): array` | Returns module routes affected by a table name | +| `analyzeImpact` | `(string $modelOrTable): array` | Full impact analysis for a model class or table name | +| `getStats` | `(): array` | Returns graph statistics (node count, edge count, etc.) | +| `getVisualGraph` | `(): array` | Returns graph data formatted for visualization | + +## Usage + +```php +use Unusualify\Modularity\Facades\RelationshipGraph; + +// Find which routes cache should be invalidated when a User is updated +$affected = RelationshipGraph::getAffectedSubmodules(\App\Models\User::class); +// → [['module' => 'Blog', 'route' => 'posts'], ...] + +// Force a rebuild after adding new module relationships +RelationshipGraph::rebuildGraph(); +``` diff --git a/docs/src/pages/system-reference/backend/facades/u-finder.md b/docs/src/pages/system-reference/backend/facades/u-finder.md new file mode 100644 index 000000000..5486af2b2 --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/u-finder.md @@ -0,0 +1,30 @@ +--- +sidebarPos: 19 +sidebarTitle: UFinder (deprecated) +--- + +# UFinder *(deprecated)* + +**Facade**: `Unusualify\Modularity\Facades\UFinder` +**Accessor**: `Unusualify\Modularity\Support\Finder::class` +**Underlying**: `Unusualify\Modularity\Support\Finder` + +::: warning Deprecated +`UFinder` is deprecated. Use [`ModularityFinder`](./modularity-finder) instead. Both facades resolve to the same underlying `Finder` class — `UFinder` exists only for backwards compatibility. +::: + +## Migration + +Replace all usages of `UFinder` with `ModularityFinder`: + +```php +// Before (deprecated) +use Unusualify\Modularity\Facades\UFinder; +$model = UFinder::getModel('blog_posts'); + +// After +use Unusualify\Modularity\Facades\ModularityFinder; +$model = ModularityFinder::getModel('blog_posts'); +``` + +All methods are identical. See [ModularityFinder](./modularity-finder) for the full method reference. diff --git a/docs/src/pages/system-reference/backend/facades/utm.md b/docs/src/pages/system-reference/backend/facades/utm.md new file mode 100644 index 000000000..2b8f65b0a --- /dev/null +++ b/docs/src/pages/system-reference/backend/facades/utm.md @@ -0,0 +1,30 @@ +--- +sidebarPos: 18 +sidebarTitle: Utm +--- + +# Utm + +**Facade**: `Unusualify\Modularity\Facades\Utm` +**Accessor**: `modularity.utm` +**Underlying**: `Unusualify\Modularity\Services\UtmParameters` + +Captures and persists UTM tracking parameters from the request. See [UtmParameters](/system-reference/backend/services/utm-parameters) for implementation details. + +## Usage + +```php +use Unusualify\Modularity\Facades\Utm; + +// Capture UTM params from the current request and store in session +Utm::getParameters(); + +// Retrieve stored UTM parameters +$params = Utm::getParameters(); +// → ['utm_source' => 'google', 'utm_medium' => 'cpc', ...] +``` + +## Notes + +- Called automatically by `UtmMiddleware` on every request. Direct usage is rarely needed outside of that middleware. +- Parameters are persisted in the session for a configurable TTL and exposed to Blade layouts via a view composer. diff --git a/docs/src/pages/system-reference/backend/generators/generator.md b/docs/src/pages/system-reference/backend/generators/generator.md new file mode 100644 index 000000000..9ea9ebcd8 --- /dev/null +++ b/docs/src/pages/system-reference/backend/generators/generator.md @@ -0,0 +1,92 @@ +--- +sidebarPos: 2 +sidebarTitle: Generator +--- + +# Generator + +**Class**: `Unusualify\Modularity\Generators\Generator` +**Source**: `src/Generators/Generator.php` +**Extends**: `Nwidart\Modules\Generators\Generator` +**Uses**: `ReplacementTrait` + +Abstract base class for all Modularous generators. Defines the shared property set, constructor signature, module-resolution helpers, and config path utilities that every concrete generator inherits. + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$name` | `string` | — | The route/entity name being generated (StudlyCase) | +| `$app` | `Container` | — | The Laravel service container instance | +| `$config` | `Config` | `null` | Laravel config repository | +| `$filesystem` | `Filesystem` | `null` | Laravel filesystem instance | +| `$console` | `Console` | `null` | The Artisan command instance (for output and sub-calls) | +| `$module` | `Module` | `null` | The nwidart Module instance being targeted | +| `$moduleName` | `string` | — | Name of the module, derived from `$module` | +| `$route` | `string` | — | Route name (may differ from entity name) | +| `$force` | `bool` | `false` | When true, overwrite existing files | +| `$fix` | `bool` | `false` | When true, repair/patch instead of creating from scratch | +| `$test` | `bool` | `false` | When true, run in dry-run mode — prints what would be created | + +## Constructor + +```php +public function __construct( + string $name, + ?Config $config = null, + ?Filesystem $filesystem = null, + ?Console $console = null, + ?Module $module = null +) +``` + +All dependencies are optional to allow incremental construction via setters. When `$module` is supplied at construction time, `$moduleName` is set automatically. + +## Key Methods + +### Module resolution + +```php +$generator->setModule('Posts'); // resolves by name via Modularous::find() +$generator->getModule(); // returns Module instance +``` + +`setModule()` calls `Modularity::find($module)` and also re-initialises any module-dependent state (e.g. translation paths in `RouteGenerator`). + +### Config helpers + +| Method | Description | +|--------|-------------| +| `generatorConfig($key)` | Returns a `GeneratorPath` object for the generator config key (e.g. `'repository'`, `'route-controller'`) | +| `getModularityGeneratorConfig($key)` | Raw config value from `modularity.paths.generator.{key}` | +| `getTargetPath()` | Returns `$module->getPath()` or `false` if no module is set | + +### Fluent setters + +All properties have matching `set*()/get*()` pairs: `setName`, `setConfig`, `setFilesystem`, `setConsole`, `setRoute`, `setForce`, `setFix`, `setTest`. + +## Abstract Method + +```php +abstract protected function generate(): int; +``` + +Every concrete generator must implement `generate()`. Return `0` for success, `E_ERROR` on failure. + +## Usage + +Never instantiate `Generator` directly. Use a concrete subclass: + +```php +$generator = new RouteGenerator( + name: 'Post', + config: app('config'), + filesystem: app('files'), + console: $this, // Artisan command instance + module: Modularity::find('Blog'), +); + +$generator->setSchema('title:string,body:text') + ->setForce(false) + ->generate(); +``` diff --git a/docs/src/pages/system-reference/backend/generators/laravel-test-generator.md b/docs/src/pages/system-reference/backend/generators/laravel-test-generator.md new file mode 100644 index 000000000..a54276df9 --- /dev/null +++ b/docs/src/pages/system-reference/backend/generators/laravel-test-generator.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 3 +sidebarTitle: LaravelTestGenerator +--- + +# LaravelTestGenerator + +**Class**: `Unusualify\Modularity\Generators\LaravelTestGenerator` +**Source**: `src/Generators/LaravelTestGenerator.php` +**Extends**: [`Generator`](./generator) + +Scaffolds a PHPUnit test file (Unit or Feature) for Modularous backend code. Mirrors the structure of [`VueTestGenerator`](./vue-test-generator) but targets the package's PHP test directory. + +## Test Types + +| Type key | Import dir | Target dir | Naming | Stub | +|----------|-----------|------------|--------|------| +| `unit` | `Unit/` | `Unit` | PascalCase | `tests/laravel-unit` | +| `feature` | `Feature/` | `Feature` | PascalCase | `tests/laravel-feature` | + +## Target Path + +All PHP test files are written to the package's `src/Tests` directory: + +``` +get_modularity_vendor_path('src/Tests')/{target_dir}/{kebab-name}.php +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$type` | `string` | Active test type key (`unit` or `feature`) | +| `$subImportDir` | `string\|null` | Optional subdirectory appended to the import path | +| `$subTargetDir` | `string\|null` | Optional subdirectory appended to the target path | + +## Key Methods + +### `setType(string $type)` + +Sets the active type. Must be `'unit'` or `'feature'`. + +### `getTypeTargetDir(): string` + +Returns `'Unit'` or `'Feature'` based on the active type. + +### `getTestFileName(): string` + +Returns `{kebab-name}.php`. + +### `generate(): int` + +Renders the type-specific stub with four replacements and writes the file: + +| Replacement | Value | +|-------------|-------| +| `$STUDLY_NAME$` | StudlyCase name | +| `$CAMEL_CASE$` | camelCase name | +| `$NAMESPACE$` | `test/{target_dir}/{test-file-name}` | +| `$IMPORT$` | Import path with `.php` extension | + +Files are never overwritten if they already exist. + +## Usage + +```php +$generator = new LaravelTestGenerator('PostRepository', $config, $filesystem, $console, $module); +$generator + ->setType('unit') + ->generate(); +// → Creates: src/Tests/Unit/post-repository.php +``` diff --git a/docs/src/pages/system-reference/backend/generators/overview.md b/docs/src/pages/system-reference/backend/generators/overview.md new file mode 100644 index 000000000..4df884b4e --- /dev/null +++ b/docs/src/pages/system-reference/backend/generators/overview.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Generators +--- + +# Generators + +Generators are the scaffolding engine behind Modularous `make:*` and `make:route` commands. They produce the full set of PHP and JS files that constitute a new module route, plus the test scaffolding for both the frontend and backend. + +## Class Hierarchy + +``` +Generator (abstract) ← NwidartGenerator + ReplacementTrait +├── RouteGenerator ← full-stack route scaffolding (primary generator) +├── StubsGenerator ← stub-only regeneration (fix/patch workflow) +├── VueTestGenerator ← Vitest/Jest test file scaffolding +└── LaravelTestGenerator ← PHPUnit test file scaffolding +``` + +## Generator Reference + +| Generator | Source | Responsibility | +|-----------|--------|----------------| +| [`Generator`](./generator) | `Generators/Generator.php` | Abstract base — shared properties, module resolution, config path helpers | +| [`RouteGenerator`](./route-generator) | `Generators/RouteGenerator.php` | Creates the full set of files for a new module route (model, migration, controller, repository, request, translations, permissions) | +| [`StubsGenerator`](./stubs-generator) | `Generators/StubsGenerator.php` | Regenerates stub-based files only; supports selective overwrite via `only`/`except` lists | +| [`VueTestGenerator`](./vue-test-generator) | `Generators/VueTestGenerator.php` | Scaffolds a Vitest/Jest test file for a Vue component, composable, utility, or store | +| [`LaravelTestGenerator`](./laravel-test-generator) | `Generators/LaravelTestGenerator.php` | Scaffolds a PHPUnit Unit or Feature test file | + +## How Generators Are Invoked + +Generators are not called directly in application code — they are orchestrated by Artisan commands: + +| Command | Generator | +|---------|-----------| +| `modularity:make:route` | `RouteGenerator` | +| `modularity:fix:route` | `RouteGenerator` (with `--fix`) | +| `modularity:make:stubs` | `StubsGenerator` | +| `modularity:make:test:vue` | `VueTestGenerator` | +| `modularity:make:test:laravel` | `LaravelTestGenerator` | diff --git a/docs/src/pages/system-reference/backend/generators/route-generator.md b/docs/src/pages/system-reference/backend/generators/route-generator.md new file mode 100644 index 000000000..51516a106 --- /dev/null +++ b/docs/src/pages/system-reference/backend/generators/route-generator.md @@ -0,0 +1,150 @@ +--- +sidebarPos: 4 +sidebarTitle: RouteGenerator +--- + +# RouteGenerator + +**Class**: `Unusualify\Modularity\Generators\RouteGenerator` +**Source**: `src/Generators/RouteGenerator.php` +**Extends**: [`Generator`](./generator) +**Uses**: `ManageNames` + +The primary generator — invoked by `modularity:make:route`. Produces the complete set of PHP files for a new module route: config entry, model, migration(s), controllers, repository, form request, translation keys, and Spatie permissions. + +## Additional Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$migrate` | `bool` | `true` | Run `modularity:migrate` after generation | +| `$migration` | `bool` | `true` | Generate a migration file | +| `$plain` | `bool` | `false` | Skip file generation; only write the config entry | +| `$type` | `string` | `'web'` | Route type | +| `$schema` | `string` | — | Column definition string (e.g. `title:string,body:text`) | +| `$rules` | `string` | — | Validation rules string (e.g. `title=required\|min:3`) | +| `$relationships` | `string` | — | Pipe-separated relationship schemas | +| `$useDefaults` | `bool` | — | Include default columns (id, timestamps, etc.) | +| `$customModel` | `string\|null` | — | Fully-qualified class name of an existing model to use instead of generating a new one | +| `$traits` | `Collection` | `[]` | Repository/model traits to apply | +| `$tableName` | `string\|null` | — | Override the auto-derived DB table name | + +## `generate()` Flow + +``` +generate() +├── [test mode] runTest() → dry-run output, return 0 +└── [normal mode] + ├── updateConfigFile() or fixConfigFile() + ├── addLanguageVariable() + └── [not plain] + ├── updateRoutesStatuses() — enable route in module activator + ├── generateFolders() — create missing module directories + ├── generateResources() — model, migration, controllers, repo, request… + ├── generateFiles() — stub-based files (routes, Vue pages, etc.) + ├── createRoutePermissions() — Spatie permissions + └── [migrate=true] modularity:migrate +``` + +After generation, runs `composer run-script pint modules/{module}` to auto-format the new files. + +## `generateResources()` + +| Resource | Command | Condition | +|----------|---------|-----------| +| Admin controller | `modularity:make:controller` | `paths.generator.route-controller` enabled | +| API controller | `modularity:make:controller:api` | `paths.generator.route-controller-api` enabled | +| Front controller | `modularity:make:controller:front` | `paths.generator.route-controller-front` enabled | +| Model | `modularity:make:model` | Always | +| Main migration | `modularity:make:migration` | No custom model — creates `create_{table}_table` | +| Add-columns migration | `modularity:make:migration` | Custom model present — creates `add_{table}_table` | +| Extra pivot migrations | `modularity:make:migration` | `belongsToMany` relationships in schema | +| Extra morph migrations | `modularity:make:migration` | `morphedByMany` relationships in schema | +| Repository | `modularity:make:repository` | `paths.generator.repository` enabled | +| Form request | `modularity:make:request` | `paths.generator.route-request` enabled | +| API resource | `module:make-resource` | `paths.generator.route-resource` enabled | +| Service provider | `module:make-provider` | Interactive prompt (or unit test mode) | +| Middleware | `module:make-middleware` | Interactive prompt (or unit test mode) | + +## `updateConfigFile()` + +Builds the route entry and writes it to the module's `Config/config.php`: + +```php +$route_array = [ + 'name' => 'Post', + 'headline' => 'Posts', + 'url' => 'posts', + 'route_name' => 'post', + 'icon' => '$submodule', + 'title_column_key' => 'title', // first 'name' or 'title' header, else first column + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + 'isRowEditing' => false, + 'rowActionsType'=> 'inline', + ], + 'headers' => [...], // from SchemaParser::getHeaderFormats() + 'inputs' => [...], // from SchemaParser::getInputFormats() +]; +``` + +If the config file already exists, uses `add_route_to_config()`. Otherwise writes the full config via `php_array_file_content()`. + +## `fixConfigFile()` + +Called when `$fix = true`. Reads the existing config and merges the generated route array on top without overwriting manually set values. Preserves the existing `name`, `headline`, `icon`, `table_options`, `headers`, and `inputs` if already present. + +## `addLanguageVariable()` + +Adds a translation entry to the `modules` group for every installed locale: + +``` +Key: {module_snake}.{route_snake}.name +Value: "{Headline} | {Plural} | {n} {Plural}" +``` + +Example for a route named `Post` in module `Blog`: +``` +blog.post.name = "Post | Posts | {n} Posts" +``` + +## `createRoutePermissions()` + +If `Modules\SystemUser\Repositories\PermissionRepository` exists, seeds the following Spatie permissions (guard = Modularity auth guard): + +| Permission suffix | Ability | +|------------------|---------| +| `_create` | Create records | +| `_view` | View/list records | +| `_edit` | Edit records | +| `_delete` | Soft-delete | +| `_force-delete` | Permanent delete | +| `_restore` | Restore soft-deleted | +| `_duplicate` | Duplicate a record | +| `_reorder` | Change sort order | +| `_bulk` | Bulk operations | +| `_bulk-delete` | Bulk soft-delete | +| `_bulk-force-delete` | Bulk permanent delete | +| `_bulk-restore` | Bulk restore | + +## `generateExtraMigrations()` + +Scans `$relationships` for pivot-table relationships and generates dedicated migration files: + +| Relationship type | Migration name | +|------------------|----------------| +| `belongsToMany` | `create_{source}_{target}_table` | +| `morphedByMany` | `create_{morph_pivot_name}_table` | + +## Schema Format + +```php +// $schema — column definitions +$generator->setSchema('title:string,body:text,status:enum("draft","published")'); + +// $rules — validation rules +$generator->setRules('title=required|min:3|unique:posts,body=required'); + +// $relationships — pipe-separated +$generator->setRelationships('tags:belongsToMany|author:belongsTo'); +``` diff --git a/docs/src/pages/system-reference/backend/generators/stubs-generator.md b/docs/src/pages/system-reference/backend/generators/stubs-generator.md new file mode 100644 index 000000000..ea660eb83 --- /dev/null +++ b/docs/src/pages/system-reference/backend/generators/stubs-generator.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 5 +sidebarTitle: StubsGenerator +--- + +# StubsGenerator + +**Class**: `Unusualify\Modularity\Generators\StubsGenerator` +**Source**: `src/Generators/StubsGenerator.php` +**Extends**: [`Generator`](./generator) + +A lighter variant of [`RouteGenerator`](./route-generator) that only regenerates stub-based files. It does not touch the module config, run migrations, create models, or seed permissions. Used by fix/patch commands to refresh specific generated files (controllers, Vue pages, routes) without disrupting the rest of the route. + +## Additional Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$onlyStubs` | `array` | `[]` | When set, only these stub keys will be (re)written | +| `$exceptStubs` | `array` | `[]` | When set, all stubs except these will be (re)written | + +## Methods + +### `setOnly(array $only)` + +Restricts stub generation to the listed stub keys. Only meaningful when `$fix = true`. + +```php +$generator->setOnly(['controller', 'vue-index']); +``` + +### `setExcept(array $except)` + +Regenerates all stubs **except** the listed stub keys. Only meaningful when `$fix = true`. + +```php +$generator->setExcept(['migration', 'model']); +``` + +### `generate(): int` + +Validates that the route config exists, then delegates entirely to `generateFiles()`. No config writes, no resource commands, no migrations. + +### `generateFiles()` + +Iterates the stubs defined in `modularity.stubs.files` and writes each one if: +- The file does not yet exist, **or** +- `forcibleStub($stub)` returns `true` + +### `forcibleStub(string $stub): bool` + +Determines whether an existing file should be overwritten: + +| State | Behaviour | +|-------|-----------| +| `$force = true` | Always overwrite every stub | +| `$fix = true` + `$onlyStubs` set | Overwrite only stubs in the `onlyStubs` list | +| `$fix = true` + `$exceptStubs` set | Overwrite all stubs not in the `exceptStubs` list | +| Neither flag | Never overwrite existing files | + +## Use Case + +```php +// Regenerate only the controller and Vue index page for the 'Post' route +$generator = new StubsGenerator('Post', $config, $filesystem, $console, $module); +$generator + ->setFix(true) + ->setOnly(['controller', 'vue-index']) + ->generate(); +``` + +This is the generator used when running `modularity:fix:stubs` — it lets developers refresh auto-generated boilerplate without losing manual edits to files like the migration or model. diff --git a/docs/src/pages/system-reference/backend/generators/vue-test-generator.md b/docs/src/pages/system-reference/backend/generators/vue-test-generator.md new file mode 100644 index 000000000..2e7bd0b79 --- /dev/null +++ b/docs/src/pages/system-reference/backend/generators/vue-test-generator.md @@ -0,0 +1,81 @@ +--- +sidebarPos: 6 +sidebarTitle: VueTestGenerator +--- + +# VueTestGenerator + +**Class**: `Unusualify\Modularity\Generators\VueTestGenerator` +**Source**: `src/Generators/VueTestGenerator.php` +**Extends**: [`Generator`](./generator) + +Scaffolds a Vitest/Jest test file for a Vue frontend artefact. Supports four test types — component, utility, composable/hook, and Pinia store — each with its own import path convention, naming convention, and stub template. + +## Test Types + +| Type key | Import dir | Target dir | Naming | Stub | File extension | +|----------|-----------|------------|--------|------|---------------| +| `component` | `components/` | `components` | PascalCase | `tests/vue-component` | `.vue` | +| `util` | `utils/` | `utils` | CamelCase | `tests/vue-util` | `.js` | +| `hook` | `hooks/` | `composables` | CamelCase | `tests/vue-composable` | `.js` | +| `store` | `store/modules/` | `store` | KebabCase | `tests/vue-store` | `.js` | + +## Target Path + +All Vue test files are written to the package's `vue/test` directory: + +``` +get_modularity_vendor_path('vue/test')/{target_dir}/{kebab-name}.test.js +``` + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$type` | `string` | Active test type key (one of `component`, `util`, `hook`, `store`) | +| `$subImportDir` | `string\|null` | Optional subdirectory appended to the import path | +| `$subTargetDir` | `string\|null` | Optional subdirectory appended to the target path (currently unused in path resolution) | + +## Key Methods + +### `setType(string $type)` + +Sets the active type. Must be one of the keys in `$types`. + +### `getTypeImportDir(): string` + +Builds the full import path for the stub's `$IMPORT$` replacement: + +``` +{import_dir}/{subImportDir?}/{ConventionName}.{extension} +``` + +### `getTypeTargetDir(): string` + +Returns the target subdirectory (e.g. `components`, `store`). + +### `getTestFileName(): string` + +Returns `{kebab-name}.test.js`. + +### `generate(): int` + +Renders the type-specific stub with four replacements and writes the file: + +| Replacement | Value | +|-------------|-------| +| `$STUDLY_NAME$` | StudlyCase name | +| `$CAMEL_CASE$` | camelCase name | +| `$NAMESPACE$` | `test/{target_dir}/{test-file-name}` | +| `$IMPORT$` | Full import path with extension | + +## Usage + +```php +$generator = new VueTestGenerator('UserCard', $config, $filesystem, $console, $module); +$generator + ->setType('component') + ->setSubImportDir('users') // optional: components/users/UserCard.vue + ->generate(); +// → Creates: vue/test/components/user-card.test.js +``` diff --git a/docs/src/pages/system-reference/backend/helpers/array.md b/docs/src/pages/system-reference/backend/helpers/array.md new file mode 100644 index 000000000..fdf1186dd --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/array.md @@ -0,0 +1,130 @@ +--- +sidebarPos: 2 +sidebarTitle: array +--- + +# array + +**File**: `src/Helpers/array.php` + +Array manipulation helpers used throughout Modularous for merging, exporting, and transforming PHP arrays. + +## Functions + +### `array_merge_recursive_distinct` + +```php +array_merge_recursive_distinct(array &$array1, array &$array2): array +``` + +Deep-merges two arrays. When the same key exists in both arrays and both values are arrays, it recurses. Otherwise the value from `$array2` **overwrites** `$array1`. + +Unlike PHP's `array_merge_recursive`, this does not create sub-arrays for scalar key conflicts. + +--- + +### `array_merge_recursive_preserve` + +```php +array_merge_recursive_preserve(array ...$arrays): array +``` + +Variadic deep merge. Calls `array_merge_recursive_distinct` across all provided arrays in order. Used as the standard deep-merge utility across Modularous controllers and config helpers. + +--- + +### `array_export` + +```php +array_export(array $array, string $indent = ''): string +``` + +Converts an array to a formatted PHP `array(...)` string suitable for writing to a `.php` config file. Recursively indents nested arrays. + +--- + +### `php_array_file_content` + +```php +php_array_file_content(array $array): string +``` + +Wraps `array_export()` output in a full PHP file template: + +```php +<?php + +return array_export($array); +``` + +Used by code generators that write config files. + +--- + +### `array_to_object` + +```php +array_to_object(array $array): object +``` + +Recursively converts an associative array to a `stdClass` object tree using `json_decode(json_encode($array))`. + +--- + +### `object_to_array` + +```php +object_to_array(object $object): array +``` + +Recursively converts a `stdClass` object tree back to an associative array. + +--- + +### `nested_array_merge` + +```php +nested_array_merge(array $array1, array $array2): array +``` + +Alias for `array_merge_recursive_distinct` — accepts both arrays by value. + +--- + +### `array_merge_conditional` + +```php +array_merge_conditional(array $base, array $conditional, bool $condition): array +``` + +If `$condition` is `true`, merges `$conditional` into `$base` using `array_merge_recursive_distinct`. Otherwise returns `$base` unchanged. + +--- + +### `change_array_file_array` + +```php +change_array_file_array(string $filePath, string $key, mixed $value): void +``` + +Reads a PHP array config file, sets `$key` to `$value` using `Arr::set()`, then writes the updated array back to the file using `php_array_file_content()`. + +--- + +### `add_route_to_config` + +```php +add_route_to_config(string $filePath, string $routeName, array $routeConfig): void +``` + +Loads a route config file and appends `$routeConfig` under `routes.$routeName`. Uses `change_array_file_array` internally. + +--- + +### `array_except` + +```php +array_except(array $array, array|string $keys): array +``` + +Returns a copy of `$array` with the given `$keys` removed. Thin wrapper over `Arr::except()`. diff --git a/docs/src/pages/system-reference/backend/helpers/column.md b/docs/src/pages/system-reference/backend/helpers/column.md new file mode 100644 index 000000000..d6fd97c75 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/column.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 3 +sidebarTitle: column +--- + +# column + +**File**: `src/Helpers/column.php` + +Helpers for configuring and translating data-table column definitions passed to Modularous index views. + +## Functions + +### `configure_table_columns` + +```php +configure_table_columns(array $columns): array +``` + +Runs each column definition through `HeaderHydrator`, which resolves defaults (width, sortable, type, etc.) and returns the normalized column array ready for the Vue data table. + +--- + +### `hydrate_table_column_translation` + +```php +hydrate_table_column_translation(array $column): array +``` + +Translates the column's `title` key using the `table-headers.*` translation namespace: + +```php +$translation = ___("table-headers.{$column['title']}"); +``` + +If a translation exists for the key, it replaces the raw `title` value. Falls back to the original value if no translation is found. + +--- + +### `hydrate_table_columns_translations` + +```php +hydrate_table_columns_translations(array $columns): array +``` + +Maps `hydrate_table_column_translation` over an array of column definitions. Called internally by `configure_table_columns` after hydration. diff --git a/docs/src/pages/system-reference/backend/helpers/component.md b/docs/src/pages/system-reference/backend/helpers/component.md new file mode 100644 index 000000000..fbc237a8e --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/component.md @@ -0,0 +1,68 @@ +--- +sidebarPos: 4 +sidebarTitle: component +--- + +# component + +**File**: `src/Helpers/component.php` + +Helpers that produce Vue component configuration arrays for Modularous modal and recursive-content patterns. + +## Functions + +### `modularity_response_modal_body_component` + +```php +modularity_response_modal_body_component(string $component, array $props = []): array +``` + +Returns a config array describing a `ue-recursive-stuff` Vue component to be used as a modal body. Merges `$props` into the component descriptor. + +--- + +### `modularity_modal_service` + +```php +modularity_modal_service(string $component, array $props = []): array +``` + +Builds a `ue-recursive-stuff` modal service config — the structure that drives Modularous's dynamic modal system. Returns: + +```php +[ + 'type' => 'ue-recursive-stuff', + 'component' => $component, + ...$props, +] +``` + +--- + +### `modularity_modal_service_form` + +```php +modularity_modal_service_form(string $component, array $props = []): array +``` + +Variant of `modularity_modal_service` that wraps a `ue-form` component inside the modal body. Used when the modal content is a form. + +--- + +### `modularity_new_modal_service` + +```php +modularity_new_modal_service(string $component, array $props = []): array +``` + +Builds a new-style modal service config using the updated modal component structure introduced in a later Modularous version. Use this for new modals; prefer `modularity_modal_service` only for legacy modals. + +--- + +### `modularity_new_response_modal_body_component` + +```php +modularity_new_response_modal_body_component(string $component, array $props = []): array +``` + +New-style modal body component descriptor. Pair with `modularity_new_modal_service` when building modals with the updated component structure. diff --git a/docs/src/pages/system-reference/backend/helpers/composer.md b/docs/src/pages/system-reference/backend/helpers/composer.md new file mode 100644 index 000000000..13b4a8a4d --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/composer.md @@ -0,0 +1,110 @@ +--- +sidebarPos: 5 +sidebarTitle: composer +--- + +# composer + +**File**: `src/Helpers/composer.php` + +Helpers for Composer package introspection, environment detection, and `.env` file manipulation. + +## Functions + +### `get_installed_composer` + +```php +get_installed_composer(): array +``` + +Reads and decodes `vendor/composer/installed.json`, returning the full installed package manifest as a PHP array. + +--- + +### `get_package_installed_version` + +```php +get_package_installed_version(string $package): string|null +``` + +Searches the installed Composer packages for `$package` (e.g. `unusualify/modularity`) and returns its installed version string, or `null` if not found. + +--- + +### `is_modularity_development` + +```php +is_modularity_development(): bool +``` + +Returns `true` when the `unusualify/modularity` package source type is `path` (i.e. it is loaded from a local path repository, as in a development monorepo setup). + +--- + +### `is_modularity_production` + +```php +is_modularity_production(): bool +``` + +Returns `true` when `is_modularity_development()` is `false` — i.e. the package is installed from Packagist or a VCS source. + +--- + +### `get_modularity_vendor_dir` + +```php +get_modularity_vendor_dir(): string +``` + +Returns the absolute path to the Composer vendor directory (e.g. `/var/www/vendor`). + +--- + +### `get_modularity_vendor_path` + +```php +get_modularity_vendor_path(string $path = ''): string +``` + +Appends `$path` to the vendor directory: `vendor/unusualify/modularity/{$path}`. + +--- + +### `get_modularity_src_path` + +```php +get_modularity_src_path(string $path = ''): string +``` + +Returns the path to the `src/` directory inside the package: `vendor/unusualify/modularity/src/{$path}`. + +--- + +### `modularity_path` + +```php +modularity_path(string $path = ''): string +``` + +Alias for `get_modularity_vendor_path()`. Preferred shorthand in most internal callers. + +--- + +### `get_package_version` + +```php +get_package_version(string $package): string +``` + +Returns the installed version of any Composer package. Wrapper around `get_package_installed_version` with a fallback to `'unknown'`. + +--- + +### `set_env_file` + +```php +set_env_file(string $key, string $value, string $envPath = null): void +``` + +Updates or inserts a `KEY=value` pair in the `.env` file. Uses a regex replace to overwrite an existing key or appends the pair if the key is not present. `$envPath` defaults to `base_path('.env')`. diff --git a/docs/src/pages/system-reference/backend/helpers/connector.md b/docs/src/pages/system-reference/backend/helpers/connector.md new file mode 100644 index 000000000..b910aebc6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/connector.md @@ -0,0 +1,90 @@ +--- +sidebarPos: 6 +sidebarTitle: connector +--- + +# connector + +**File**: `src/Helpers/connector.php` + +Entrypoint helpers for the Modularous **Connector** system — the mechanism that links form inputs to repository actions across modules. + +## Functions + +### `init_connector` + +```php +init_connector(string $moduleName, string $routeName, array $config = []): mixed +``` + +Main entrypoint. Resolves the module and route, then delegates to `find_target` and `exec_target`. Returns the result of the connector action. + +--- + +### `find_module_and_route` + +```php +find_module_and_route(string $moduleName, string $routeName): array +``` + +Looks up the module via `Modularity::find()` and returns `[$module, $route]`. Throws if either cannot be resolved. + +--- + +### `find_module_route_names` + +```php +find_module_route_names(string $moduleName): array +``` + +Returns all route names registered in a module. Uses `Modularity::find($moduleName)->getRouteNames()`. + +--- + +### `get_connector_event` + +```php +get_connector_event(string $moduleName, string $routeName, string $event): mixed +``` + +Retrieves the connector event configuration for a specific module/route/event combination from the module's route config. + +--- + +### `change_connector_event` + +```php +change_connector_event(string $moduleName, string $routeName, string $event, mixed $value): void +``` + +Mutates the connector event value in the module's route config. Used during form schema build to wire input events. + +--- + +### `find_target` + +```php +find_target(array $config): array +``` + +Parses the connector `$config` to determine the target type (`uri` or `repository`) and returns a normalised descriptor array including the resolved endpoint URL or repository class name. + +--- + +### `exec_target` + +```php +exec_target(array $target, string $method, array $params = []): mixed +``` + +Executes the resolved target. For `repository` targets it calls `App::make($target['repository'])->{$method}(...$params)`. For `uri` targets it returns the URL for the frontend to call. + +--- + +### `get_item_columns` + +```php +get_item_columns(string $moduleName, string $routeName): array +``` + +Returns the column configuration for the items list of a connector-backed input — resolves the module's repository, calls `getColumns()`, and formats them for the frontend select/combobox input. diff --git a/docs/src/pages/system-reference/backend/helpers/db.md b/docs/src/pages/system-reference/backend/helpers/db.md new file mode 100644 index 000000000..48b9ff886 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/db.md @@ -0,0 +1,28 @@ +--- +sidebarPos: 7 +sidebarTitle: db +--- + +# db + +**File**: `src/Helpers/db.php` + +Database utility helpers. + +## Functions + +### `database_exists` + +```php +database_exists(string $database, string $connection = null): bool +``` + +Checks whether a database with the given name exists on the configured connection by attempting a PDO connection directly to that schema. + +```php +if (database_exists('my_app_db')) { + // safe to run migrations +} +``` + +Uses `try { new PDO(...) }` internally — returns `true` if the connection succeeds and `false` on any `PDOException`. Used by `create:database` and `install` console commands before creating or migrating a schema. diff --git a/docs/src/pages/system-reference/backend/helpers/format.md b/docs/src/pages/system-reference/backend/helpers/format.md new file mode 100644 index 000000000..b6664d4c6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/format.md @@ -0,0 +1,112 @@ +--- +sidebarPos: 8 +sidebarTitle: format +--- + +# format + +**File**: `src/Helpers/format.php` + +The largest helper file — 45+ functions covering string casing, class reflection, code generation, data access, and closure transformation utilities. + +## String Casing + +| Function | Signature | Description | +|----------|-----------|-------------| +| `lowerName` | `(string $name): string` | Lowercases the name | +| `studlyName` | `(string $name): string` | Converts to `StudlyCase` via `Str::studly()` | +| `camelCase` | `(string $name): string` | Converts to `camelCase` via `Str::camel()` | +| `kebabCase` | `(string $name): string` | Converts to `kebab-case` via `Str::kebab()` | +| `snakeCase` | `(string $name): string` | Converts to `snake_case` via `Str::snake()` | +| `pluralize` | `(string $name): string` | Returns plural form via `Str::plural()` | +| `singularize` | `(string $name): string` | Returns singular form via `Str::singular()` | +| `headline` | `(string $name): string` | Converts to `Headline Case` via `Str::headline()` | +| `tableName` | `(string $name): string` | Converts to plural snake_case table name | +| `camelCaseToWords` | `(string $name): string` | Splits `camelCase` into space-separated words | +| `is_plural` | `(string $name): bool` | Returns whether the string is already plural | + +## Foreign Key / Morph Naming + +| Function | Signature | Description | +|----------|-----------|-------------| +| `makeForeignKey` | `(string $model): string` | `user` → `user_id` | +| `makeMorphToName` | `(string $model): string` | Returns morph-to relation name | +| `makeMorphName` | `(string $model): string` | Returns morph type name (without `_type`) | +| `makeMorphForeignKey` | `(string $model): string` | Returns `{model}_id` for polymorphic pivot | +| `makeMorphForeignType` | `(string $model): string` | Returns `{model}_type` for polymorphic pivot | +| `makeMorphToMethodName` | `(string $model): string` | Returns `morphTo` method name | +| `makeMorphPivotTableName` | `(string $model): string` | Returns pivot table name for a morph pivot | +| `getMorphModelName` | `(string $tableName): string` | Strips `able`/`ables` suffix to get model name | + +## Class / Namespace Reflection + +| Function | Signature | Description | +|----------|-----------|-------------| +| `abbreviation` | `(string $name): string` | Builds an abbreviation from underscored words (e.g. `user_profile` → `up`) | +| `get_class_short_name` | `(string $class): string` | Returns the unqualified class name from a FQCN | +| `class_resolution` | `(string $class): string` | Resolves a class alias or short name to its FQCN | +| `class_namespace` | `(string $class): string` | Extracts the namespace from a FQCN | +| `fileTrace` | `(string $pattern): string` | Searches `debug_backtrace()` for a file path matching the regex `$pattern` | + +## Code Generation + +These helpers write PHP source fragments used by `make:*` console commands. + +| Function | Signature | Description | +|----------|-----------|-------------| +| `get_file_string` | `(string $path): string` | Reads a stub file | +| `replace_curly_braces` | `(string $stub, array $replacements): string` | Replaces `{{KEY}}` tokens in a stub string | +| `indent` | `(string $content, int $level = 1): string` | Indents content by `$level × 4` spaces | +| `comment_string` | `(string $text): string` | Wraps text in a `/* ... */` comment block | +| `method_string` | `(string $name, string $body, ...): string` | Generates a PHP method declaration | +| `attribute_string` | `(string $name, mixed $value): string` | Generates a PHP class attribute line | +| `concatenate_path` | `(string ...$parts): string` | Joins path segments with `/`, deduplicating slashes | +| `concatenate_namespace` | `(string ...$parts): string` | Joins namespace segments with `\\` | +| `get_file_class` | `(string $path): string` | Extracts the class name from a PHP file | + +## Validation / Rules + +| Function | Signature | Description | +|----------|-----------|-------------| +| `parseRulesSchema` | `(string|array $rules): array` | Normalizes Laravel validation rules to array form | +| `formatRulesSchema` | `(array $rules): string` | Converts rules array back to pipe-delimited string | + +## Data Access + +| Function | Signature | Description | +|----------|-----------|-------------| +| `getValueOrNull` | `(array $data, string $key): mixed` | Returns `$data[$key]` or `null` if missing | +| `tryOperation` | `(callable $fn, mixed $default = null): mixed` | Executes `$fn`, returns `$default` on any `Throwable` | +| `data_get_with_dot_keys` | `(array $data, string $key): mixed` | Like `data_get()` but treats literal dot-keys as single keys first | +| `data_set_with_dot_keys` | `(array &$data, string $key, mixed $value): void` | Like `data_set()` but handles literal dot-keys | +| `wrapImplode` | `(string $separator, array $array, string $prepend, string $append): string` | Implodes array with optional prefix/suffix | + +## Relationship Map + +| Function | Signature | Description | +|----------|-----------|-------------| +| `laravelRelationshipMap` | `(): array` | Returns the cached Eloquent relationship type map | +| `saveLaravelRelationshipMap` | `(array $map): void` | Persists the relationship map to the modularity cache | + +## Routing / Display + +| Function | Signature | Description | +|----------|-----------|-------------| +| `modelShowFormat` | `(mixed $model): string` | Returns the display string for a model instance | +| `nestedRouteNameFormat` | `(string $routeName): string` | Formats a nested route name for display | + +## User + +| Function | Signature | Description | +|----------|-----------|-------------| +| `get_user_profile` | `(): array` | Returns the authenticated user's profile data array | +| `name_surname_resolver` | `(string $fullName): array` | Splits a full name string into `['name' => ..., 'surname' => ...]` | + +## Variable Replacement + +| Function | Signature | Description | +|----------|-----------|-------------| +| `replace_variables_from_haystack` | `(string $haystack, array $vars): string` | Replaces `{KEY}` tokens using `$vars` | +| `extract_schema_extensions` | `(array $schema): array` | Extracts `ext` values from a form schema | +| `transform_closure_value` | `(mixed $value, bool $forceArray = false): mixed` | If `$value` is a `Closure`, calls it and returns the result | +| `transform_closure_values` | `(array $data, bool $forceArray = false): array` | Maps `transform_closure_value` over an array | diff --git a/docs/src/pages/system-reference/backend/helpers/front.md b/docs/src/pages/system-reference/backend/helpers/front.md new file mode 100644 index 000000000..16e4aa63b --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/front.md @@ -0,0 +1,68 @@ +--- +sidebarPos: 9 +sidebarTitle: front +--- + +# front + +**File**: `src/Helpers/front.php` + +Frontend-oriented helpers for resolving host URLs and SVG symbol identifiers used by the Modularous UI. + +## Functions + +### `getHost` + +```php +getHost(): string +``` + +Returns the current application host URL (`scheme://host`) without a trailing slash. Used by `permalink` and `permalinkPrefix` input extensions to build full preview URLs. + +--- + +### `getModularityDefaultUrls` + +```php +getModularityDefaultUrls(): array +``` + +Returns an array of default URLs that the Modularous frontend injects into its global configuration: + +```php +[ + 'admin' => adminUrlPrefix(), + 'system' => systemUrlPrefix(), + // additional entries from modularity config +] +``` + +--- + +### `modularity_svg_symbol_exists` + +```php +modularity_svg_symbol_exists(string $symbol): bool +``` + +Checks whether an SVG symbol with the given identifier exists in the compiled Modularous sprite sheet. Returns `true` if the symbol is found. + +--- + +### `get_modularity_logo_symbol` + +```php +get_modularity_logo_symbol(string|array $candidates): string +``` + +Accepts a single symbol name or an ordered list of candidates and returns the first one that exists in the SVG sprite. Falls back to `'main-logo'` if no candidate is found. Used for locale-specific logo variants (e.g. `mini-logo-dark-tr`, then `mini-logo-dark`). + +--- + +### `get_modularity_locale_symbol` + +```php +get_modularity_locale_symbol(string $locale): string +``` + +Returns the SVG symbol identifier for a given locale flag icon. Falls back to a generic globe symbol if no locale-specific flag is registered. diff --git a/docs/src/pages/system-reference/backend/helpers/i18n.md b/docs/src/pages/system-reference/backend/helpers/i18n.md new file mode 100644 index 000000000..06ef7e041 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/i18n.md @@ -0,0 +1,97 @@ +--- +sidebarPos: 10 +sidebarTitle: i18n +--- + +# i18n + +**File**: `src/Helpers/i18n.php` + +Internationalization and translation helpers that extend Laravel's built-in `trans()` with Modularous-specific JSON fallback, label resolution, and Vue store formatting. + +## Functions + +### `modularityTrans` + +```php +modularityTrans(string $key, array $replace = [], string $locale = null): string +``` + +Looks up a translation key in the `modularity::` namespace. Returns the raw key if no translation is found, making missing keys visible rather than silently empty. + +--- + +### `___` (triple underscore) + +```php +___(string $key, array $replace = [], string $locale = null): string +``` + +The primary Modularous translation helper. Resolution order: + +1. `trans($key)` — standard Laravel lookup +2. `trans("{$module}::{$key}")` — module-namespaced lookup +3. JSON translation files for the active locale +4. Returns `$key` unchanged if nothing matches + +Use `___` instead of `__` throughout Modularous controllers and views. + +--- + +### `hasUnusualTrans` + +```php +hasUnusualTrans(string $key): bool +``` + +Returns `true` if the given key resolves to a different string than the key itself — i.e. a real translation exists. + +--- + +### `trans_replacements` + +```php +trans_replacements(string $translation, array $replacements): string +``` + +Applies `[':KEY' => $value]` replacements to an already-translated string. Thin wrapper over Laravel's `str_replace` replacement pattern. + +--- + +### `getLabelFromLocale` + +```php +getLabelFromLocale(string $key, string $locale = null): string +``` + +Retrieves the label for `$key` from the locale-specific translation file rather than the active app locale. Useful for generating labels in a target language during export or email generation. + +--- + +### `getCode2LanguageTexts` + +```php +getCode2LanguageTexts(): array +``` + +Returns the full ISO 639-1 language code → language name map as an array (e.g. `['en' => 'English', 'tr' => 'Turkish', ...]`). Used to populate language selector dropdowns. + +--- + +### `getLanguagesForVueStore` + +```php +getLanguagesForVueStore(): array +``` + +Returns the active application locales formatted for the Vuex/Pinia store: + +```php +[ + ['code' => 'en', 'label' => 'English'], + ['code' => 'tr', 'label' => 'Turkish'], + // ... +] +``` + +Only includes locales listed in `config('translatable.locales')`. diff --git a/docs/src/pages/system-reference/backend/helpers/input.md b/docs/src/pages/system-reference/backend/helpers/input.md new file mode 100644 index 000000000..785d49a8f --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/input.md @@ -0,0 +1,148 @@ +--- +sidebarPos: 11 +sidebarTitle: input +--- + +# input + +**File**: `src/Helpers/input.php` + +Form input processing helpers — the PHP side of the Modularous form schema pipeline. These functions hydrate, normalize, and extend input definitions before they are serialized to the frontend. + +## Pipeline Overview + +``` +configure_input() + ↓ +hydrate_input_type() → merges registered input-type presets + ↓ +hydrate_input_connector() → resolves connector endpoint/repository + ↓ +format_input() → type-specific logic (group, wrap, morphTo, polymorphic, title) + ↓ +hydrate_input() → runs InputHydrator + ↓ +hydrate_input_extension() → processes ext patterns (permalink, filter, toggle, etc.) + ↓ +modularity_format_input() → merges with default input, keys by name + ↓ +modularity_format_inputs() → maps over schema array +``` + +## Functions + +### `configure_input` + +```php +configure_input(array $input): array +``` + +Normalizes shorthand input config. Numeric keys (flags like `'required'`) are converted to `['required' => true]` key-value pairs. Label keys are auto-translated via `___('form-labels.*')`. + +--- + +### `modularity_default_input` + +```php +modularity_default_input(): array +``` + +Returns the default input configuration from `modularity.default_input` config key. Merged into every input by `modularity_format_input`. + +--- + +### `hydrate_input_type` + +```php +hydrate_input_type(array $input): array +``` + +Looks up `$input['type']` in `modularity.input_types` config and merges the preset defaults with the provided input array. Allows centralized type defaults (e.g. all `select` inputs get `itemValue: 'id'` automatically). + +--- + +### `hydrate_input_connector` + +```php +hydrate_input_connector(array &$input, string $moduleName = null, string $routeName = null): void +``` + +Resolves `connector` shorthand strings to actual `endpoint` URLs or `repository` class references. Connector format: + +``` +'moduleName:routeName|uri:index' +'moduleName:routeName|repository:list' +``` + +--- + +### `hydrate_input_extension` + +```php +hydrate_input_extension(array &$input, &$data, &$arrayable, array $inputs): void +``` + +Processes the `ext` key on an input definition. Supported extension patterns: + +| Pattern | Effect | +|---------|--------| +| `date` / `time` | Sets default to current date/time | +| `permalink:slug` | Adds a readonly slug input and a `formatPermalink` event | +| `permalinkPrefix:slug` | Adds a `formatPermalinkPrefix` event | +| `lock:url:url` | Adds a `formatLock` event | +| `filter:target:prop` | Resolves a filter endpoint and adds `formatFilter` event | +| `preview:field` | Adds `formatPreview` event | +| `set:target:prop` | Adds `formatSet` event | +| `clearModel:target` | Adds `formatClearModel` event | +| `resetItems:target` | Adds `formatResetItems` event | +| `prependSchema:...` | Adds `formatPrependSchema` event | +| `removeValue:target` | Adds `formatRemoveValue` event | +| `toggleInput:target:value:level` | Adds `formatToggleInput` event | + +Events are stored pipe-delimited in `$input['event']` and consumed by the Vue form engine. + +--- + +### `hydrate_input` + +```php +hydrate_input(array $input, $module = null, $routeName = null, $skipQueries = null): array +``` + +Delegates to `InputHydrator::hydrate()` — the class-based hydration layer that handles relationships, repositories, and query-based item population. + +--- + +### `format_input` + +```php +format_input(array $input, ...): array +``` + +Handles type-specific input processing: +- **`group` / `wrap`**: Builds nested schema with parent name prefixing and default collection +- **`morphTo`**: Builds cascading select inputs for polymorphic foreign key selection +- **`polymorphic`**: Splits into a type combobox and an ID combobox from a `morphs` array +- **`title`**: Applies display defaults (padding, weight, class, color) + +Returns `[$processedInput, $isArrayable]`. + +--- + +### `modularity_format_input` + +```php +modularity_format_input(array $input, ...): array +``` + +Full pipeline entry for a single input: resolves closures, calls `format_input`, merges defaults, applies `configure_input`, and returns `['name' => $normalizedInput]`. + +--- + +### `modularity_format_inputs` + +```php +modularity_format_inputs(array $inputs, ...): array +``` + +Maps `modularity_format_input` over an array of input definitions, returning the keyed schema array ready for the frontend. diff --git a/docs/src/pages/system-reference/backend/helpers/media.md b/docs/src/pages/system-reference/backend/helpers/media.md new file mode 100644 index 000000000..7def7428a --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/media.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 12 +sidebarTitle: media +--- + +# media + +**File**: `src/Helpers/media.php` + +File and media utility helpers for human-readable size formatting and safe filename generation. + +## Functions + +### `bytesToHuman` + +```php +bytesToHuman(float $bytes): string +``` + +Converts a raw byte count to a human-readable string with appropriate unit: + +```php +bytesToHuman(1536); // "1.5 Kb" +bytesToHuman(2097152); // "2 Mb" +``` + +Units: `B`, `Kb`, `Mb`, `Gb`, `Tb`, `Pb`. Divides by 1024 at each step and rounds to 2 decimal places. + +--- + +### `replaceAccents` + +```php +replaceAccents(string $str): string +``` + +Transliterates accented and non-ASCII characters to their closest ASCII equivalent using `iconv('UTF-8', 'ASCII//TRANSLIT', ...)`. Example: `café` → `cafe`. + +--- + +### `sanitizeFilename` + +```php +sanitizeFilename(string $filename): string +``` + +Produces a safe, lowercase filename suitable for storage and URL usage: + +1. Calls `replaceAccents()` to remove diacritics +2. Replaces spaces and `%20` with `-` +3. Removes all characters except alphanumerics, `-`, and `.` +4. Removes all but the last `.` (to keep the extension) +5. Collapses multiple consecutive `-` into one +6. Removes `-` immediately before `.` +7. Lowercases the result + +```php +sanitizeFilename('Héllo Wörld (2).JPG'); +// → "hello-world-2.jpg" +``` diff --git a/docs/src/pages/system-reference/backend/helpers/migrations.md b/docs/src/pages/system-reference/backend/helpers/migrations.md new file mode 100644 index 000000000..664df46dd --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/migrations.md @@ -0,0 +1,155 @@ +--- +sidebarPos: 13 +sidebarTitle: migrations +--- + +# migrations + +**File**: `src/Helpers/migrations_helpers.php` + +Blueprint helper functions that provide standardized field presets for Modularous migration files. They respect the `use_big_integers_on_migrations` config option to switch between `int` and `bigInt` column types. + +## Integer Type Helpers + +### `modularityIncrementsMethod` + +```php +modularityIncrementsMethod(): string +``` + +Returns `'bigIncrements'` or `'increments'` depending on the `use_big_integers_on_migrations` config flag. + +### `modularityIntegerMethod` + +```php +modularityIntegerMethod(): string +``` + +Returns `'bigInteger'` or `'integer'` depending on the same config flag. + +--- + +## Table Field Presets + +### `createDefaultTableFields` + +```php +createDefaultTableFields(Blueprint $table, bool $has_name = true): void +``` + +Adds the primary key column using `modularityIncrementsMethod()`. Minimal base — does not add `name`, timestamps, or soft deletes. + +--- + +### `createDefaultExtraTableFields` + +```php +createDefaultExtraTableFields( + Blueprint $table, + bool $softDeletes = true, + bool $published = true, + bool $publishDates = false, + bool $visibility = false +): void +``` + +Adds optional standard columns: + +| Parameter | Column added | +|-----------|-------------| +| `$published = true` | `published` (boolean, default false) | +| `$publishDates = true` | `publish_start_date`, `publish_end_date` (nullable timestamps) | +| `$visibility = true` | `public` (boolean, default true) | +| always | `created_at`, `updated_at` | +| `$softDeletes = true` | `deleted_at` | + +--- + +### `createDefaultTranslationsTableFields` + +```php +createDefaultTranslationsTableFields( + Blueprint $table, + string $modelName, + string $tableName = null, + string $foreignKey = null +): void +``` + +Scaffolds a standard `*_translations` pivot table: +- `id` (increments) +- `{model}_id` (unsigned integer, foreign key → parent table with CASCADE delete) +- `deleted_at`, `created_at`, `updated_at` +- `locale` (string 7, indexed) +- `active` (boolean, default true) +- Unique index on `({model}_id, locale)` + +Handles long model names (> 18 chars) by using `abbreviation()` in the foreign key index name. + +--- + +### `createDefaultSlugsTableFields` + +```php +createDefaultSlugsTableFields( + Blueprint $table, + string $tableNameSingular, + string $tableNamePlural = null +): void +``` + +Scaffolds a `*_slugs` table: +- `id`, `{model}_id` (foreign key with CASCADE delete), `deleted_at`, timestamps +- `slug` (string), `locale` (string 7, indexed), `active` (boolean) + +--- + +### `createDefaultRelationshipTableFields` + +```php +createDefaultRelationshipTableFields( + Blueprint $table, + string $table1NameSingular, + string $table2NameSingular, + string $table1NamePlural = null, + string $table2NamePlural = null +): void +``` + +Scaffolds a many-to-many pivot table with: +- `{table1}_id` and `{table2}_id` as foreign keys (cascade delete/update) +- Composite primary key on both IDs + +--- + +### `createDefaultMorphPivotTableFields` + +```php +createDefaultMorphPivotTableFields( + Blueprint $table, + string $modelName = null, + string $tableName = null, + string $morphedTableName = null +): void +``` + +Scaffolds a morph pivot table (`*ables`): +- `{model}_id` foreign key → parent table (cascade) +- `{morph_name}_type` + `{morph_name}_id` via `uuidMorphs()` with a named index + +--- + +### `createDefaultRevisionsTableFields` + +```php +createDefaultRevisionsTableFields( + Blueprint $table, + string $tableNameSingular, + string $tableNamePlural = null +): void +``` + +Scaffolds a `*_revisions` table: +- `id`, `{model}_id` (foreign → parent, cascade delete), `user_id` (nullable, set null on delete) +- `created_at`, `updated_at` +- `payload` (JSON) diff --git a/docs/src/pages/system-reference/backend/helpers/module.md b/docs/src/pages/system-reference/backend/helpers/module.md new file mode 100644 index 000000000..f4c089218 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/module.md @@ -0,0 +1,259 @@ +--- +sidebarPos: 14 +sidebarTitle: module +--- + +# module + +**File**: `src/Helpers/module.php` + +Module context, configuration, permission, and debug helpers. This is the largest helper file for module-aware code — used heavily in service providers, console commands, and module config files. + +## Module Context + +Functions that resolve the "current module" by tracing the PHP call stack. + +### `modularityBaseKey` + +```php +modularityBaseKey(string $notation = null): string +``` + +Returns the root config key for Modularous (default: `modularity`). If `MODULARITY_BASE_NAME` is set in `.env`, it is used instead. Optional `$notation` is appended with a `.` separator. + +```php +modularityBaseKey('locale'); // → 'modularity.locale' +``` + +--- + +### `curtModule` + +```php +curtModule(string $file = null): Module +``` + +Resolves and returns the current module instance by tracing the file path in the call stack to extract a module name, then calling `Modularity::find()`. + +--- + +### `curtModuleName` + +```php +curtModuleName(string $file = null): string +``` + +Extracts the module name from the call stack by matching the `Modules/{ModuleName}` pattern. Throws `ModularityException` if it cannot be determined. + +--- + +### `curtModuleUrlPrefix` / `curtModuleRouteNamePrefix` + +```php +curtModuleUrlPrefix(string $file = null): string +curtModuleRouteNamePrefix(string $file = null): string +``` + +Returns the URL prefix / route name prefix of the current module. + +--- + +### `curtModuleStudlyName` / `curtModuleLowerName` / `curtModuleSnakeName` + +```php +curtModuleStudlyName(string $file = null): string +curtModuleLowerName(string $file = null): string +curtModuleSnakeName(string $file = null): string +``` + +Return the current module name in the requested casing. + +--- + +## Trait / Class Inspection + +### `classUsesDeep` + +```php +classUsesDeep(mixed $class, bool $autoload = true): array +``` + +Returns all traits used by a class and all of its parent classes — including traits used by other traits (recursive). Returns a flat, unique array of trait names. + +--- + +### `classHasTrait` + +```php +classHasTrait(mixed $class, string $trait): bool +``` + +Returns `true` if `$class` (or any parent/trait in the hierarchy) uses `$trait`. Uses `classUsesDeep` internally. + +--- + +## Route Helpers + +### `moduleRoute` + +```php +moduleRoute( + string $moduleName, + string $prefix, + string $action = '', + array $parameters = [], + bool $absolute = true, + bool $singleton = false +): string +``` + +Generates a full URL for a module route. Automatically appends `:id` for edit/show/update/destroy/duplicate actions on non-singleton resources. Throws `ModularityException` with full context on route generation failure. + +--- + +### `modularityRoute` + +```php +modularityRoute( + string $route, + string $prefix, + string $action = '', + array $parameters = [], + bool $absolute = true +): string +``` + +Similar to `moduleRoute` but for Modularous built-in routes (not module-specific). + +--- + +## Trait Options + +### `getModularityTraits` / `activeModularityTraits` / `modularityTraitOptions` + +```php +getModularityTraits(): array +activeModularityTraits(array $traitOptions): Collection +modularityTraitOptions(bool $asSignature = false): array|string +``` + +Read the registered trait list from `modularity.traits` config. `modularityTraitOptions` can return either a plain array or a formatted Symfony `InputOption` signature string for `make:*` commands. + +--- + +## Configuration + +### `modularityConfig` + +```php +modularityConfig(string $notation = null, mixed $default = ''): mixed +``` + +Shorthand for `config(modularityBaseKey($notation), $default)`. The most-used helper across the entire codebase. + +--- + +## Permissions + +### `formatPermissionName` + +```php +formatPermissionName(string $routeName, string $permissionType): string +``` + +Returns a kebab-cased permission name: `route-name_permission-type`. + +### `formatPermissionRecord` + +```php +formatPermissionRecord(string $routeName, string $permissionType, string $guardName): array +``` + +Returns `['name' => ..., 'guard_name' => ...]` for a Spatie Permission record. + +### `routePermissionRecords` + +```php +routePermissionRecords(string $routeName, string $guardName, array $cases = null): array +``` + +Returns all permission records for a route across all `Permission` enum cases. + +### `permissionRecordsFromRoutes` + +```php +permissionRecordsFromRoutes(array $routes, string $guardName): array +``` + +Returns permission records for multiple routes combined. + +--- + +## Debug / Utility + +### `ifdd` + +```php +ifdd(bool $condition, mixed ...$vars): void +``` + +Conditional `dd()`. Dumps all `$vars` and exits only if `$condition` is true. Uses `VarDumper::dump` directly. + +--- + +### `exceptionalRunningInConsole` + +```php +exceptionalRunningInConsole(): bool +``` + +Returns `true` if the app is NOT running specific module-generation console commands. Used to skip expensive boot steps when running `make:module`, `make:route`, etc. + +--- + +### `backtrace_formatter` / `backtrace_formatted` + +```php +backtrace_formatter(array $carry, array $item): array +backtrace_formatted(): array +``` + +`backtrace_formatted()` returns a clean associative array of the current call stack: `['file' => ['line' => N, 'function' => 'fn']]`. + +--- + +### `benchmark` + +```php +benchmark( + callable $callback, + string $label = null, + bool $die = false, + string $unit = 'milliseconds', + string &$elapsedString = null +): mixed +``` + +Wraps `$callback` with timing. Logs to the `modularity-benchmark` channel: +- At `emergency` level if elapsed exceeds `benchmark_emergency_time` config (default 1000ms) +- At `debug` level if `benchmark_log_level` is `'debug'` + +Set `$die = true` to throw immediately with the elapsed time (useful for profiling in development). Set `$label` to identify the operation in logs. Only active when `benchmark_enabled` config is `true`. + +--- + +### `mergeConfigFrom` + +```php +mergeConfigFrom(string $path, string $key): void +``` + +Loads a PHP config file from `$path` and deep-merges it (via `array_merge_recursive_preserve`) into the existing `$key` config value. Overrides Laravel's default `mergeConfigFrom` which uses a shallow `array_merge`. + +### `findParentRoute` + +```php +findParentRoute(array $config): array +``` + +Returns the first route in a module config that has `'parent' => true`. diff --git a/docs/src/pages/system-reference/backend/helpers/overview.md b/docs/src/pages/system-reference/backend/helpers/overview.md new file mode 100644 index 000000000..a1d26a27d --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/overview.md @@ -0,0 +1,42 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Helper Functions + +Modularous ships 15 PHP helper files, loaded globally via `BaseServiceProvider::registerHelpers()` using a glob over `src/Helpers/*.php`. All functions are wrapped in `function_exists()` guards so host apps can override any helper. + +## Files Overview + +| File | Functions | Purpose | +|------|-----------|---------| +| [array.php](./array) | 11 | Deep-merge, export, and transform arrays | +| [column.php](./column) | 3 | Table column configuration and translation hydration | +| [component.php](./component) | 5 | Vue modal/component config builders | +| [composer.php](./composer) | 8 | Composer package introspection and env utilities | +| [connector.php](./connector) | 8 | Module connector system entrypoints | +| [db.php](./db) | 1 | Database existence check | +| [format.php](./format) | 45+ | String, class, code-generation, and data helpers | +| [front.php](./front) | 5 | Frontend URL and SVG symbol helpers | +| [i18n.php](./i18n) | 7 | Translation and language data helpers | +| [input.php](./input) | 7 | Form input processing, hydration, and extension | +| [media.php](./media) | 3 | File size formatting and filename sanitization | +| [migrations.php](./migrations) | 8 | Blueprint schema field presets | +| [module.php](./module) | 18 | Module context, config, permission, and debug helpers | +| [router.php](./router) | 3 | Route resolution and URL query helpers | +| [sources.php](./sources) | 14 | Application data for Inertia: navigation, auth, localization | + +## How Helpers Are Loaded + +```php +// BaseServiceProvider +protected function registerHelpers(): void +{ + foreach (glob(__DIR__ . '/../Helpers/*.php') as $file) { + require_once $file; + } +} +``` + +All helpers are available in controllers, views, Blade templates, and anywhere in the Laravel request lifecycle after the service provider has booted. diff --git a/docs/src/pages/system-reference/backend/helpers/router.md b/docs/src/pages/system-reference/backend/helpers/router.md new file mode 100644 index 000000000..8bb40b0e9 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/router.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 15 +sidebarTitle: router +--- + +# router + +**File**: `src/Helpers/router.php` + +Routing and URL helpers for resolving route names, building query strings, and merging URL parameters. + +## Functions + +### `previous_route_name` + +```php +previous_route_name(): string|null +``` + +Returns the route name of the previous URL (from `url()->previous()`), or `null` if the URL does not match any registered route. Uses the router's internal route collection to do a reverse lookup. + +```php +$prev = previous_route_name(); +// → 'admin.products.index' +``` + +--- + +### `array_to_query_string` + +```php +array_to_query_string(array $data): string +``` + +Converts a data array to a URL query string. Objects and associative arrays are JSON-encoded before being passed to `http_build_query()` with RFC 3986 encoding: + +```php +array_to_query_string(['filter' => ['status' => 'active'], 'page' => 2]); +// → 'filter=%7B%22status%22%3A%22active%22%7D&page=2' +``` + +--- + +### `merge_url_query` + +```php +merge_url_query(string $url, object|array $data): string +``` + +Parses an existing URL, merges `$data` into its existing query parameters, and returns the reconstructed URL. Accepts both arrays and objects for `$data`. + +```php +merge_url_query('https://app.test/admin/products?page=1', ['page' => 2, 'sort' => 'name']); +// → 'https://app.test/admin/products?page=2&sort=name' +``` + +--- + +### `resolve_route` + +```php +resolve_route(string|array $definition): string +``` + +Resolves a route definition to a URL. Accepts: +- A plain string route name: `'admin.products.index'` +- An array `[$routeName, $params]` + +Tries `Route::hasAdmin()` first (for admin-prefixed routes), falls back to `Route::has()`. If route parameters are present, they are extracted and the remainder is appended as a query string. + +```php +resolve_route('products.index'); +// → 'https://app.test/admin/products' + +resolve_route(['products.show', ['product' => 1, 'tab' => 'details']]); +// → 'https://app.test/admin/products/1?tab=details' +``` diff --git a/docs/src/pages/system-reference/backend/helpers/sources.md b/docs/src/pages/system-reference/backend/helpers/sources.md new file mode 100644 index 000000000..8de8b8208 --- /dev/null +++ b/docs/src/pages/system-reference/backend/helpers/sources.md @@ -0,0 +1,273 @@ +--- +sidebarPos: 16 +sidebarTitle: sources +--- + +# sources + +**File**: `src/Helpers/sources.php` + +Application data helpers that assemble the configuration objects injected into Inertia pages on every request. These functions are called by `HandleInertiaRequests` middleware to build the shared `$page->props`. + +## Locale / Timezone + +### `getLocales` + +```php +getLocales(): array +``` + +Returns the active locale list from `config('translatable.locales')`, normalising both simple (`['en', 'tr']`) and nested (`['tr' => ['TR', 'CY']]`) formats into a flat array of locale strings (e.g. `['en', 'tr-TR', 'tr-CY']`). + +--- + +### `getTimeZoneList` + +```php +getTimeZoneList(): Collection +``` + +Returns all PHP timezone identifiers with their UTC offset, sorted alphabetically. Cached forever under `timezones_list_collection`. + +```php +// ['Africa/Abidjan' => 'Africa/Abidjan (UTC +00:00)', ...] +``` + +--- + +## Form Drafts + +### `getFormDraft` + +```php +getFormDraft(string $name, array $overwrites = [], array $excludes = [], bool $preserve = true): array +``` + +Loads a named form draft from `modularity.form_drafts.{$name}` config, merges `$overwrites`, and optionally removes `$excludes` keys. When `$preserve = true` uses `array_merge_recursive_preserve`; otherwise uses plain `array_merge`. + +--- + +## Admin URL / Route Prefixes + +### `adminRouteNamePrefix` / `adminUrlPrefix` + +```php +adminRouteNamePrefix(): string +adminUrlPrefix(): string +``` + +Return the configured admin route name prefix and URL prefix respectively. Thin wrappers over `Modularity::getAdminRouteNamePrefix()` and `Modularity::getAdminUrlPrefix()`. + +--- + +### `systemUrlPrefix` / `systemRouteNamePrefix` + +```php +systemUrlPrefix(): string +systemRouteNamePrefix(): string +``` + +Return the system-settings URL prefix (default: `'system-settings'`) and its snake_case route name equivalent. + +--- + +## Themes + +### `builtInModularityThemes` + +```php +builtInModularityThemes(): Collection +``` + +Returns a `Collection` of built-in Modularous SASS themes as `['theme-name' => 'Theme Name']`, scanned from `vue/src/sass/themes/*/` (excluding `customs/`). + +--- + +### `customModularityThemes` + +```php +customModularityThemes(): Collection +``` + +Returns custom themes from `resources/vendor/modularity/themes/*/` as `['theme-name' => 'Theme Name']`. + +--- + +## Translations + +### `get_translations` + +```php +get_translations(): array +``` + +Returns all registered translations from the Laravel translator. Cached in the file store under `modularity-languages` for 600 seconds. + +--- + +### `clear_translations` + +```php +clear_translations(): void +``` + +Forgets the `modularity-languages` cache entry, forcing the next `get_translations()` call to rebuild. + +--- + +## Inertia Shared Data Builders + +The following functions each build one section of the data shared with every Inertia page. They are called from `HandleInertiaRequests::share()`. + +### `get_modularity_navigation_config` + +```php +get_modularity_navigation_config(): array +``` + +Returns the navigation config for the current user's role: + +```php +[ + 'current_url' => '...', + 'sidebar' => [...], // role-based: default / superadmin / client / guest + 'breadcrumbs' => [], + 'profileMenu' => [...], + 'sidebarBottom' => [...], +] +``` + +--- + +### `get_modularity_authorization_config` + +```php +get_modularity_authorization_config(): array +``` + +Returns the user's authorization state: + +```php +[ + 'isSuperAdmin' => bool, + 'isClient' => bool, + 'roles' => ['admin', ...], + 'permissions' => ['view-products' => true, ...], +] +``` + +All Gate abilities are evaluated and included as key → bool pairs. + +--- + +### `get_modularity_impersonation_config` + +```php +get_modularity_impersonation_config(): array +``` + +Returns the impersonation state and endpoints: + +```php +[ + 'active' => bool, + 'impersonated' => bool, + 'stopRoute' => 'https://...', + 'route' => 'https://.../:id', + 'fetchEndpoint' => 'https://.../users', + // input appearance defaults +] +``` + +--- + +### `get_modularity_localization_config` + +```php +get_modularity_localization_config(): array +``` + +Returns locale settings and the merged language strings: + +```php +[ + 'locale' => 'tr', + 'fallback_locale' => 'en', + 'lang' => [...], // fallback + current merged +] +``` + +--- + +### `get_modularity_head_layout_config` + +```php +get_modularity_head_layout_config(array $data): array +``` + +Returns the page head data (title etc.) from `$data`, merging any `_headLayoutData` override. + +--- + +### `get_modularity_inertia_main_configuration` + +```php +get_modularity_inertia_main_configuration(array $data): array +``` + +Assembles the complete main layout configuration object shared with every Inertia page: + +```php +[ + 'headerTitle' => '...', + 'hideDefaultSidebar' => false, + 'fixedAppBar' => false, + 'appBarOrder' => 0, + 'sidebarAttributes' => ['logoSymbol' => '...'], + 'navigation' => get_modularity_navigation_config(), + 'impersonation' => get_modularity_impersonation_config(), + 'authorization' => get_modularity_authorization_config(), +] +``` + +--- + +### `get_modularity_ui_preferences` + +```php +get_modularity_ui_preferences(): array +``` + +Merges PHP config defaults with the authenticated user's stored `ui_preferences`: + +```php +[ + 'sidebar' => [...], + 'topbar' => [...], + 'bottomNavigation' => [...], +] +``` + +Returns only config defaults for guests. + +--- + +## Currency / VAT (Client-specific) + +### `get_user_currency_vat_rates` + +```php +get_user_currency_vat_rates(): Collection +``` + +Returns the VAT rates for the authenticated client user's payment country. Returns an empty collection for non-client users or guests. + +--- + +### `get_user_payment_country_currencies` + +```php +get_user_payment_country_currencies(): Collection +``` + +Returns the payment currencies available for the authenticated client user's country, derived from `get_user_currency_vat_rates()`. diff --git a/docs/src/pages/system-reference/backend/http/controllers/api-controller.md b/docs/src/pages/system-reference/backend/http/controllers/api-controller.md new file mode 100644 index 000000000..328d61396 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/api-controller.md @@ -0,0 +1,105 @@ +--- +sidebarPos: 3 +sidebarTitle: ApiController +--- + +# ApiController + +**File**: `src/Http/Controllers/ApiController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `CoreController` +**Traits**: `ApiResponses`, `ApiVersioning`, `ApiAuthentication`, `ApiRateLimiting`, `ApiValidation`, `ApiPagination`, `ApiFiltering`, `ApiSorting`, `ApiRelationships` + +Abstract base controller for RESTful JSON APIs. Provides versioning, rate limiting, relationship loading, bulk operations, and a standardised response envelope on top of `CoreController`. + +## Constructor + +```php +public function __construct(Application $app, Request $request) +``` + +Calls `CoreController::__construct()` then runs: + +1. `setApiVersion()` — resolves version from request header or default. +2. `setApiResourceClasses()` — finds the API resource and collection classes. +3. `setApiDefaults()` — loads per-route API config (per-page limits, includes, etc.). + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$apiVersion` | `string` | `'v1'` | Active API version | +| `$defaultPerPage` | `int` | `15` | Default items per page | +| `$maxPerPage` | `int` | `100` | Maximum allowed items per page | +| `$defaultIncludes` | `array` | `[]` | Relationships always loaded | +| `$availableIncludes` | `array` | `[]` | Relationships the client may request | +| `$apiResourceClass` | `string` | — | `JsonResource` class for single items | +| `$apiResourceCollectionClass` | `string` | — | `ResourceCollection` class | +| `$wrapResponses` | `bool` | `true` | Wrap data in a `data` envelope | +| `$responseMetadata` | `array` | `[]` | Extra fields merged into every response | + +## Standard CRUD Endpoints + +| Method | HTTP | Path | Description | +|--------|------|------|-------------| +| `index()` | GET | `/api/{resource}` | Paginated list with filtering and sorting | +| `show(int $id)` | GET | `/api/{resource}/{id}` | Single item with includes | +| `store()` | POST | `/api/{resource}` | Create — returns 201 | +| `update(int $id)` | PUT | `/api/{resource}/{id}` | Update — returns 200 | +| `destroy(int $id)` | DELETE | `/api/{resource}/{id}` | Soft-delete — returns 200 | + +## Extended Endpoints + +### `bulk(): JsonResponse` + +Fetches multiple items by an array of IDs. Accepts `ids[]` in the request body. + +### `search(): JsonResponse` + +Full-text search across the module's searchable columns. Returns a paginated collection. + +### `filters(): JsonResponse` + +Returns all available filters, sort fields, and includable relationships for the client to use in subsequent requests. + +### `meta(): JsonResponse` + +Returns metadata about the resource — relationship counts, available scopes, and config flags. + +## Pagination + +### `getPerPage(): int` + +Returns `per_page` from the request, clamped between `1` and `$maxPerPage`. + +## Relationships / Includes + +### `getIncludes(): array` + +Parses the `include` query parameter (comma-separated) and validates each name against `$availableIncludes`. + +### `getIncludesForEagerLoading(): array` + +Returns includes formatted for `with()` — may attach constraints (e.g. ordering) to specific relationships. + +## Response Helpers + +### `respondWithResource($resource, int $status = 200): JsonResponse` + +Wraps a single `JsonResource` instance in the configured envelope and returns it. + +### `respondWithCollection($collection, int $status = 200): JsonResponse` + +Wraps a `ResourceCollection` with pagination meta. + +### `respondWithData($data, int $status = 200): JsonResponse` + +Returns arbitrary data. When `$wrapResponses` is `true`, nests under a `data` key. + +## Rate Limiting + +Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`) are added to every response via the `ApiRateLimiting` trait. Limits are configured per route in the module config. + +## Versioning + +The API version is read from the `Accept` header (e.g. `Accept: application/vnd.api+json;version=v2`) or falls back to `$apiVersion`. The resolved version is available throughout the request lifecycle. diff --git a/docs/src/pages/system-reference/backend/http/controllers/api-language-controller.md b/docs/src/pages/system-reference/backend/http/controllers/api-language-controller.md new file mode 100644 index 000000000..5707c6947 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/api-language-controller.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 2 +sidebarTitle: API\LanguageController +--- + +# API\LanguageController + +**File**: `src/Http/Controllers/API/LanguageController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\API` +**Extends**: `Illuminate\Routing\Controller` + +Serves all translation strings as a JSON payload for frontend consumption. Results are cached to a file store for 10 minutes to avoid recomputing translations on every request. + +## Constructor + +```php +public function __construct(Translation $translation) +``` + +Injects the `JoeDixon\Translation\Drivers\Translation` driver used to load translation files. + +## Methods + +### `index(Request $request): JsonResponse` + +Returns a JSON object containing all application translations, keyed by locale and translation group. + +**Caching behaviour**: + +| Detail | Value | +|--------|-------| +| Cache store | `file` | +| Cache key | `modularity-languages` | +| TTL | 600 seconds (10 minutes) | + +Translations are resolved via `app('translator')->getTranslations()` which aggregates group and single-line translation files across all registered modules and the base application. + +### `store()`, `show($id)`, `update($id)`, `destroy($id)` + +Stub methods — not implemented. The controller is read-only; translation management is handled through translation files or a dedicated translation UI. + +## Usage + +The frontend fetches translations on initial page load via this endpoint and stores them in the Vue i18n instance, enabling `$t('key')` calls throughout the admin panel and public views. + +## Related + +- `ManageTranslations` trait on `BaseController` — provides server-side translation fallback chain for module-specific keys diff --git a/docs/src/pages/system-reference/backend/http/controllers/auth/complete-register-controller.md b/docs/src/pages/system-reference/backend/http/controllers/auth/complete-register-controller.md new file mode 100644 index 000000000..055b63794 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/auth/complete-register-controller.md @@ -0,0 +1,69 @@ +--- +sidebarPos: 2 +sidebarTitle: CompleteRegisterController +--- + +# CompleteRegisterController + +**File**: `src/Http/Controllers/Auth/CompleteRegisterController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\Auth` +**Extends**: `Auth\Controller` +**Traits**: `CreateVerifiedEmailAccount`, `RespondsWithJsonOrRedirect` + +Second step in the email-verified registration flow. After the user verifies their email via [PreRegisterController](./pre-register-controller), this controller displays the full registration form and creates the user account. + +## Methods + +### `broker()` + +Returns the register broker via `Register::broker()`. + +### `showCompleteRegisterForm(Request $request, $token = null): View|RedirectResponse` + +Displays the complete registration form if the email/token combination is valid. + +1. Extracts `token` from the route and `email` from the query string. +2. Validates the token exists for the email via `Register::broker('register_verified_users')->emailTokenExists()`. +3. Fires the `ModularityUserRegistering` event. +4. Builds the form schema from `getFormDraft('complete_register_form')`, pre-filling fields from the request (excluding password fields). +5. Renders the registration view. + +If the token is invalid or expired, redirects to the email form with an error. + +### `completeRegister(Request $request): JsonResponse|RedirectResponse` + +Processes the registration form submission: + +1. Validates all fields via the `CreateVerifiedEmailAccount` rules. +2. Calls `broker()->register()` with the submitted credentials. +3. On success, calls `registerEmail()` which: + - Creates the `User` record with hashed password and verified email. + - Creates a `Company` record. + - Assigns the `client-manager` role. + - Fires `ModularityUserRegistered` and `VerifiedEmailRegister` events. + - Logs the user in automatically. +4. Returns a success or failure response. + +### `sendRegisterResponse(Request $request, $response): JsonResponse|RedirectResponse` + +Returns a success response with translated message and redirect path. + +### `sendRegisterFailedResponse(Request $request, $response): JsonResponse|RedirectResponse` + +Returns a failure response with the error message under the `email` key. + +## Validation Rules + +| Field | Rules | +|-------|-------| +| `token` | required | +| `email` | required, email | +| `name` | required, no consecutive spaces | +| `surname` | required, no consecutive spaces | +| `company` | required | +| `password` | required, confirmed | + +## Related + +- [PreRegisterController](./pre-register-controller) — first step: sends the email verification link +- [RegisterController](./register-controller) — direct registration (when email verification is disabled) diff --git a/docs/src/pages/system-reference/backend/http/controllers/auth/controller.md b/docs/src/pages/system-reference/backend/http/controllers/auth/controller.md new file mode 100644 index 000000000..0162c8def --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/auth/controller.md @@ -0,0 +1,73 @@ +--- +sidebarPos: 1 +sidebarTitle: Auth\Controller +--- + +# Auth\Controller + +**File**: `src/Http/Controllers/Auth/Controller.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\Auth` +**Extends**: `Unusualify\Modularity\Http\Controllers\Controller` +**Traits**: `AuthFormBuilder`, `ManageUtilities` + +Base controller for all authentication workflows (login, registration, password reset). Applies the `modularity.guest` middleware so that authenticated users are redirected away from auth pages, and provides shared utilities for guard resolution and redirect paths. + +## Constructor + +```php +public function __construct( + ?Config $config = null, + ?Redirector $redirector = null, + ?ViewFactory $viewFactory = null +) +``` + +1. Calls the parent `Controller::__construct()`. +2. Resolves `Config`, `Redirector`, and `ViewFactory` from the container (or uses injected instances). +3. Sets `$redirectTo` from `modularity.auth_login_redirect_path` config. +4. Applies `modularity.guest` middleware, excluding actions returned by `guestMiddlewareExcept()`. + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$config` | `Config\|null` | Laravel config repository | +| `$redirector` | `Redirector\|null` | URL redirector | +| `$viewFactory` | `ViewFactory\|null` | Blade view factory | +| `$redirectTo` | `string` | Post-authentication redirect path | + +## Methods + +### `guestMiddlewareExcept(): array` + +Returns an array of method names that should be excluded from the `modularity.guest` middleware. Override in subclasses to allow authenticated users to access specific actions (e.g. `LoginController` excludes `logout`). + +Default implementation returns an empty array. + +### `guard()` + +Returns the authentication guard configured for Modularous via `Modularity::getAuthGuardName()`. + +### `redirectPath(): string` + +Returns the value of `$redirectTo` — used by Laravel's authentication traits to determine where to send the user after a successful auth action. + +## Inheritance + +All authentication controllers extend this class: + +``` +Auth\Controller +├── LoginController +├── RegisterController +├── ForgotPasswordController +├── PreRegisterController +├── CompleteRegisterController +└── ResetPasswordController +``` + +## Related + +- [Controller](../controller) — root Modularous controller (parent of this class) +- [LoginController](./login-controller) — login and 2FA +- [RegisterController](./register-controller) — user registration diff --git a/docs/src/pages/system-reference/backend/http/controllers/auth/forgot-password-controller.md b/docs/src/pages/system-reference/backend/http/controllers/auth/forgot-password-controller.md new file mode 100644 index 000000000..8848065ef --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/auth/forgot-password-controller.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 3 +sidebarTitle: ForgotPasswordController +--- + +# ForgotPasswordController + +**File**: `src/Http/Controllers/Auth/ForgotPasswordController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\Auth` +**Extends**: `Auth\Controller` +**Traits**: `SendsPasswordResetEmails` + +Sends password reset emails using Laravel's built-in password broker. The user enters their email address and receives a link to the [ResetPasswordController](./reset-password-controller). + +## Methods + +### `broker(): PasswordBroker` + +Returns the password broker configured for the Modularous auth provider via `Modularity::getAuthProviderName()`. + +### `showLinkRequestForm(): View` + +Renders the "forgot password" form where the user enters their email address. The form schema is built via `AuthFormBuilder::buildAuthViewData('forgot_password')`. + +### `sendResetLinkResponse(Request $request, $response): JsonResponse|RedirectResponse` + +Called when the reset link is sent successfully. + +- **JSON (Inertia/AJAX)**: returns a success message with `MessageStage::SUCCESS` variant. +- **Traditional**: redirects back with a `status` flash message. + +### `sendResetLinkFailedResponse(Request $request, $response): JsonResponse|RedirectResponse` + +Called when the reset link cannot be sent (e.g. email not found). + +- **JSON**: returns the error message with `MessageStage::WARNING` variant. +- **Traditional**: redirects back with the email input preserved and an `email` error. + +## Flow + +``` +User clicks "Forgot Password" on login page + └─ showLinkRequestForm() renders the email form + └─ User submits email + └─ SendsPasswordResetEmails::sendResetLinkEmail() (Laravel trait) + ├─ Success → sendResetLinkResponse() → user checks email + └─ Failure → sendResetLinkFailedResponse() → error shown +``` + +## Related + +- [ResetPasswordController](./reset-password-controller) — handles the reset link the user receives +- [PasswordController](../password-controller) — alternative password reset and first-time password generation +- [LoginController](./login-controller) — links to this controller from the login form diff --git a/docs/src/pages/system-reference/backend/http/controllers/auth/login-controller.md b/docs/src/pages/system-reference/backend/http/controllers/auth/login-controller.md new file mode 100644 index 000000000..959e86c5d --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/auth/login-controller.md @@ -0,0 +1,94 @@ +--- +sidebarPos: 4 +sidebarTitle: LoginController +--- + +# LoginController + +**File**: `src/Http/Controllers/Auth/LoginController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\Auth` +**Extends**: `Auth\Controller` +**Traits**: `AuthenticatesUsers`, `HandlesOAuth` + +Handles user login with email/password, Google 2FA verification, OAuth provider authentication, and session logout. + +## Constructor + +```php +public function __construct( + Config $config, + AuthManager $authManager, + Encrypter $encrypter, + Redirector $redirector, + ViewFactory $viewFactory +) +``` + +Sets up authentication manager, encrypter, redirector, and view factory. Configures `$redirectTo` from `modularity.auth_login_redirect_path`. + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$authManager` | `AuthManager` | Laravel auth manager | +| `$encrypter` | `Encrypter` | Encryption service (used for 2FA) | + +## Methods + +### `showForm(): View` + +Renders the login form view using the `AuthFormBuilder` trait to build the form schema and layout. + +### `showLogin2FaForm(): View` + +Renders the Google 2FA code entry form. Shown after initial email/password authentication when the user has 2FA enabled. + +### `logout(Request $request): RedirectResponse` + +Logs the user out, invalidates the session, regenerates the CSRF token, and redirects to the login form. + +### `authenticated(Request $request, $user)` + +Called by Laravel's `AuthenticatesUsers` trait after successful credential validation. Delegates to `afterAuthentication()`. + +### `afterAuthentication(Request $request, $user)` + +Post-login handler with two paths: + +1. **2FA enabled**: logs the user out temporarily, stores their ID in `session('2fa:user:id')`, and redirects to the 2FA form. +2. **No 2FA**: stores the user's timezone in the session (if provided) and returns a success response with redirect URL. + +Returns JSON for Inertia/AJAX requests or a redirect for traditional requests. + +### `login2Fa(Request $request): RedirectResponse` + +Verifies the Google 2FA one-time password. On success, logs the user in and redirects to the intended URL. On failure, redirects back to the 2FA form with an error. + +### `redirectTo(): string` + +Returns the dashboard route URL. + +### `sendFailedLoginResponse(Request $request): JsonResponse` + +Returns a JSON error response with the `auth.failed` translation message for AJAX requests, or throws a `ValidationException` for traditional requests. + +## Guest Middleware + +The `logout` action is excluded from the `modularity.guest` middleware via `guestMiddlewareExcept()`, allowing authenticated users to access the logout route. + +## OAuth Flow + +The `HandlesOAuth` trait adds OAuth provider support: + +| Method | Description | +|--------|-------------| +| `redirectToProvider($provider)` | Redirects to the OAuth provider (e.g. Google) | +| `handleProviderCallback($provider)` | Processes the OAuth callback — links or creates user | +| `showPasswordForm()` | Prompts for password when linking OAuth to existing account | +| `linkProvider()` | Confirms password and links the OAuth provider | + +## Related + +- [Auth\Controller](./controller) — base auth controller +- [ForgotPasswordController](./forgot-password-controller) — password recovery +- [RegisterController](./register-controller) — new account registration diff --git a/docs/src/pages/system-reference/backend/http/controllers/auth/pre-register-controller.md b/docs/src/pages/system-reference/backend/http/controllers/auth/pre-register-controller.md new file mode 100644 index 000000000..2f4d59b53 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/auth/pre-register-controller.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 5 +sidebarTitle: PreRegisterController +--- + +# PreRegisterController + +**File**: `src/Http/Controllers/Auth/PreRegisterController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\Auth` +**Extends**: `Auth\Controller` +**Traits**: `SendsEmailVerificationRegister` + +First step in the email-verified registration flow. Collects the user's email address, sends a verification link, and waits for the user to confirm before proceeding to account creation via [CompleteRegisterController](./complete-register-controller). + +## Constructor + +```php +public function __construct(?Application $app = null) +``` + +Calls the parent `Auth\Controller` constructor. + +## Methods + +### `broker()` + +Returns the register broker via `Register::broker()`. + +### `showEmailForm(): View` + +Renders the pre-registration form where the user enters their email address. The form is built via `AuthFormBuilder::buildAuthViewData('pre_register')`. + +### `sendVerificationLinkEmail(Request $request)` *(from trait)* + +Validates the email, sends a verification link through the register broker, and fires the `ModularityUserVerification` event on success. + +**Response on success**: redirects to the login page with a modal confirming the email was sent, or to a dedicated success page depending on the `MODULARITY_USE_REGISTRATION_REDIRECT_WITH_MODAL` environment variable. + +**Response on failure**: returns a warning with the broker error message. + +### `showSuccessForm()` *(from trait)* + +Renders a success page confirming the verification email was sent, with a button linking back to the login form. + +## Email-Verified Registration Flow + +``` +1. User visits registration page + └─ RegisterController redirects to PreRegisterController (when email_verified_register = true) + └─ showEmailForm() renders email-only form + +2. User submits email + └─ sendVerificationLinkEmail() sends signed verification link + └─ Success modal or success page displayed + +3. User clicks verification link in email + └─ CompleteRegisterController::showCompleteRegisterForm() renders full registration form + +4. User completes registration + └─ CompleteRegisterController::completeRegister() creates account +``` + +## Related + +- [CompleteRegisterController](./complete-register-controller) — second step: completes account creation after email verification +- [RegisterController](./register-controller) — direct registration (when email verification is disabled) diff --git a/docs/src/pages/system-reference/backend/http/controllers/auth/register-controller.md b/docs/src/pages/system-reference/backend/http/controllers/auth/register-controller.md new file mode 100644 index 000000000..3928bfded --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/auth/register-controller.md @@ -0,0 +1,63 @@ +--- +sidebarPos: 6 +sidebarTitle: RegisterController +--- + +# RegisterController + +**File**: `src/Http/Controllers/Auth/RegisterController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\Auth` +**Extends**: `Auth\Controller` + +Handles direct user registration (without email pre-verification). When the `email_verified_register` config option is enabled, this controller redirects users to the [PreRegisterController](./pre-register-controller) flow instead. + +## Methods + +### `showForm(): View|RedirectResponse` + +Displays the registration form. If `modularity.email_verified_register` is `true`, redirects to the email verification form (`PreRegisterController::showEmailForm`). + +### `register(Request $request): JsonResponse|RedirectResponse` + +Processes the registration submission: + +1. If `email_verified_register` is enabled, returns an error and redirects to the email form. +2. Validates the request via `validator()`. +3. Fires `ModularityUserRegistering` event. +4. Creates a `Company` record (personal if no company name provided). +5. Creates a `User` record attached to the company. +6. Assigns the `client-manager` role. +7. Fires `ModularityUserRegistered` event. +8. Returns a success response with redirect to the registration success page. + +### `validator(array $data): Validator` + +Returns a validator with the rules from `rules()`. + +### `rules(): array` + +| Field | Rules | +|-------|-------| +| `name` | required, string, max:255 | +| `surname` | required, string, max:255 | +| `email` | required, string, email, max:255, unique in users table | +| `password` | required, confirmed, meets `Password::defaults()` | + +### `success(): View` + +Renders a success page after registration, with a button linking to the login form. + +## Registration Modes + +Modularous supports two registration flows controlled by `modularity.email_verified_register`: + +| Mode | Flow | +|------|------| +| **Direct** (`false`) | `RegisterController` — user fills form → account created immediately | +| **Email-verified** (`true`) | `PreRegisterController` → email verification → `CompleteRegisterController` | + +## Related + +- [PreRegisterController](./pre-register-controller) — email verification step before registration +- [CompleteRegisterController](./complete-register-controller) — completes registration after email verification +- [LoginController](./login-controller) — post-registration login diff --git a/docs/src/pages/system-reference/backend/http/controllers/auth/reset-password-controller.md b/docs/src/pages/system-reference/backend/http/controllers/auth/reset-password-controller.md new file mode 100644 index 000000000..440d02311 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/auth/reset-password-controller.md @@ -0,0 +1,88 @@ +--- +sidebarPos: 7 +sidebarTitle: ResetPasswordController +--- + +# ResetPasswordController + +**File**: `src/Http/Controllers/Auth/ResetPasswordController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers\Auth` +**Extends**: `Auth\Controller` +**Traits**: `ResetsPasswords`, `RespondsWithJsonOrRedirect` + +Handles the password reset flow after the user clicks the reset link from the [ForgotPasswordController](./forgot-password-controller) email. Also supports a "welcome" flow for new users who receive an invitation link. + +## Methods + +### `broker(): PasswordBroker` + +Returns the password broker configured for the Modularous auth provider via `Modularity::getAuthProviderName()`. + +### `showResetForm(Request $request, $token = null): View|RedirectResponse` + +Displays the password reset form. + +1. Resolves the user from the token via `getUserFromToken()`. +2. Validates the token against the password reset table. +3. Pre-fills the form with the user's email and the token. +4. Renders the reset password view. + +If the token is invalid or expired, redirects to the forgot-password page with an error. + +### `showWelcomeForm(Request $request, $token = null): View|RedirectResponse` + +Displays the password form for new users who received a welcome/invitation email. Unlike `showResetForm`, this method does **not** check token expiry — welcome tokens remain valid until used. + +### `reset(Request $request): JsonResponse|RedirectResponse` + +Processes the password reset submission: + +1. Validates the request (password, confirmation, token, email). +2. Calls `broker()->reset()` with the credentials and a callback that invokes `resetPassword()`. +3. Returns a success or failure response. + +### `getUserFromToken(string $token): ?User` + +Resolves a user from a password reset token. Supports both: + +- **Clear tokens**: direct lookup in the password resets table. +- **Hashed tokens** (Laravel 5.4+): iterates all reset records and checks with `Hash::check()`. + +Returns `null` if no matching user is found. + +### `sendResetResponse(Request $request, $response): JsonResponse|RedirectResponse` + +Returns a success response with translated message and redirect to `$redirectPath`. + +### `sendResetFailedResponse(Request $request, $response): JsonResponse|RedirectResponse` + +Returns a failure response with the error under the `email` key. + +### `success(): View` + +Renders a success page confirming the password was reset, with a button linking to the login form. + +## Reset Flow + +``` +User clicks reset link in email + └─ showResetForm() validates token and renders password form + └─ User submits new password + └─ reset() validates and updates password + ├─ Success → redirect to login + └─ Failure → error shown on form +``` + +## Welcome Flow + +``` +Admin invites new user → welcome email sent with token + └─ showWelcomeForm() renders password form (no token expiry check) + └─ User sets password + └─ reset() saves password and logs user in +``` + +## Related + +- [ForgotPasswordController](./forgot-password-controller) — sends the reset email that leads to this controller +- [PasswordController](../password-controller) — alternative password reset controller with a slightly different flow diff --git a/docs/src/pages/system-reference/backend/http/controllers/base-controller.md b/docs/src/pages/system-reference/backend/http/controllers/base-controller.md new file mode 100644 index 000000000..7d7181e92 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/base-controller.md @@ -0,0 +1,117 @@ +--- +sidebarPos: 4 +sidebarTitle: BaseController +--- + +# BaseController + +**File**: `src/Http/Controllers/BaseController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `PanelController` +**Traits**: `ManageIndexAjax`, `ManagePrevious`, `ManageUtilities`, `ManageSingleton`, `ManageInertia`, `ManageTranslations` + +Standard CRUD controller for the Modularous admin panel. Generated module controllers extend this class and inherit all of the actions below. Override any method in the generated controller to customise behaviour for that module. + +## Constructor + +```php +public function __construct(Application $app, Request $request) +``` + +Calls `PanelController::__construct()` and sets `$viewPrefix` to `{module}::{route}` (e.g. `blog::posts`). + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$viewPrefix` | `string` | Blade/Inertia view namespace (e.g. `blog::posts`) | +| `$titleFormKey` | `string` | Model attribute used as title in form headers | + +## CRUD Actions + +### `index($parentId = null): View|JsonResponse` + +Lists all resources. Returns AJAX-formatted JSON when requested via `X-Requested-With: XMLHttpRequest`, otherwise renders the index view. Respects scopes, filters, and ordering from `PanelController`. + +### `create($parentId = null): JsonResponse|RedirectResponse|View` + +Shows the create form. Returns the form schema, field definitions, and relationship data. Redirects to index if the `create` index option is disabled. + +### `store($parentId = null): JsonResponse|RedirectResponse` + +Validates the request via `validateFormRequest()` then calls `$repository->create()`. On success: + +- Logs activity (`created`). +- Respects `cmsSaveType` header to redirect to `save-close` (index) or `save-new` (create form). +- Returns JSON for Inertia requests. + +### `show($id, $submoduleId = null): RedirectResponse|JsonResponse|View` + +Fetches and displays a single resource. Redirects to `edit` if the module config does not define a show view. + +### `edit($id): JsonResponse|RedirectResponse|View` + +Shows the edit form for a resource. Loads the form schema, relationships, and current field values. Redirects to index if the `edit` option is disabled. + +### `update($id, $submoduleId = null): JsonResponse|RedirectResponse` + +Validates then calls `$repository->update()`. On success: + +- Logs activity (`updated`). +- Respects `cmsSaveType` for redirect target. + +### `destroy($id, $submoduleId = null): JsonResponse` + +Soft-deletes the resource via `$repository->delete()`. Returns a JSON success/error response. + +### `forceDelete(): JsonResponse` + +Permanently deletes a soft-deleted resource. Requires the `forceDelete` index option to be enabled. + +### `restore(): JsonResponse` + +Restores a soft-deleted resource. Requires the `restore` index option to be enabled. + +### `duplicate($id, $submoduleId = null): JsonResponse` + +Duplicates the resource by cloning it and its relationships. Logs a `duplicated` activity event. + +## Bulk Actions + +### `bulkDelete(): JsonResponse` + +Soft-deletes multiple resources by IDs. + +### `bulkForceDelete(): JsonResponse` + +Permanently deletes multiple resources by IDs. + +### `bulkRestore(): JsonResponse` + +Restores multiple soft-deleted resources by IDs. + +### `reorder(): JsonResponse` + +Updates the sort position of multiple resources given an ordered array of IDs. + +## Save Type Behaviour + +The frontend sends a `cmsSaveType` value with every save request to control where the user is redirected after the operation: + +| `cmsSaveType` | Redirect target | +|---------------|-----------------| +| `save-close` | Index listing | +| `save-new` | Create form | +| (default) | Edit form for the saved record | + +## Translation Fallback Chain + +User-facing messages (created, updated, deleted, etc.) are resolved in this order: + +1. `{module}::{routeName}.{action}` translation key. +2. `{module}::generic.{action}` translation key. +3. Modularous default message. + +## View Rendering + +When the `ManageInertia` trait is active (detected from module config), responses are rendered via Inertia instead of Blade. The view prefix `{module}::{route}` is used for both. diff --git a/docs/src/pages/system-reference/backend/http/controllers/chat-controller.md b/docs/src/pages/system-reference/backend/http/controllers/chat-controller.md new file mode 100644 index 000000000..a1d42e431 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/chat-controller.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 5 +sidebarTitle: ChatController +--- + +# ChatController + +**File**: `src/Http/Controllers/ChatController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `App\Http\Controllers\Controller` + +Handles chat messages, attachments, and pinned messages for a `Chat` model instance. Supports both paginated fetching and time-based queries for real-time catch-up. + +## Methods + +### `index(Request $request, Chat $chat): JsonResponse` + +Returns messages for a chat room. + +- When `from` is present in the request, returns all messages newer than that timestamp (real-time catch-up). User's own messages are excluded from this query. +- When `from` is absent, returns a paginated list of all messages ordered newest-first. + +**Request parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `from` | `datetime` | Only return messages newer than this timestamp | + +### `store(Request $request, Chat $chat): JsonResponse` + +Creates a new chat message. Accepts an optional `attachments` field (Filepond temporary file keys). The parent `Chat` model is touched (updated_at refreshed) after the message is stored. + +### `attachments(Request $request, Chat $chat): JsonResponse` + +Returns all file attachments for a chat room, ordered by upload date. + +### `update(Request $request, $id): JsonResponse` + +Updates the body of an existing message. Touches the parent `Chat` model's timestamp. + +### `pinnedMessage(Request $request, $id): JsonResponse` + +Returns the currently pinned message for the chat identified by `$id`. + +### `destroy(Request $request, ChatMessage $message): JsonResponse` + +Deletes a chat message. Intended for message owners or admins. + +## Filepond Attachments + +Attachments are uploaded via Filepond before the message is created. The `store` action resolves each Filepond temporary key to a permanent `File` record and associates it with the message. + +## Related + +- [FilepondController](./filepond-controller) — handles temporary Filepond uploads +- [FileLibraryController](./file-library-controller) — permanent file management diff --git a/docs/src/pages/system-reference/backend/http/controllers/controller.md b/docs/src/pages/system-reference/backend/http/controllers/controller.md new file mode 100644 index 000000000..8358fdb37 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/controller.md @@ -0,0 +1,37 @@ +--- +sidebarPos: 6 +sidebarTitle: Controller +--- + +# Controller + +**File**: `src/Http/Controllers/Controller.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Illuminate\Routing\Controller` +**Traits**: `AuthorizesRequests`, `DispatchesJobs`, `ValidatesRequests` + +Root controller for the Modularous package. Provides two behaviours on top of Laravel's base: optional custom exception handler binding and a middleware removal utility. + +## Constructor + +```php +public function __construct() +``` + +When `modularity.bind_exception_handler` is `true` in the application config, binds Modularous's own exception handler into the container so that API and admin-panel errors are formatted consistently. + +## Methods + +### `removeMiddleware` + +```php +protected function removeMiddleware(string $middleware): void +``` + +Removes a named middleware from the controller's middleware stack. Iterates the internal `$middleware` array and unsets any entry whose `middleware` key matches `$middleware`. + +Used by sub-controllers (e.g. `PanelController`, `ProfileController`) to opt specific actions out of inherited middleware without rewriting the full stack. + +## Inheritance + +All Modularous controllers extend this class either directly or via `CoreController` → `PanelController` → `BaseController`. Auth controllers extend it through `Auth\Controller`. diff --git a/docs/src/pages/system-reference/backend/http/controllers/core-controller.md b/docs/src/pages/system-reference/backend/http/controllers/core-controller.md new file mode 100644 index 000000000..d48964ba8 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/core-controller.md @@ -0,0 +1,130 @@ +--- +sidebarPos: 7 +sidebarTitle: CoreController +--- + +# CoreController + +**File**: `src/Http/Controllers/CoreController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Controller` +**Traits**: `AuthorizesRequests`, `DispatchesJobs`, `ValidatesRequests`, `ManageNames`, `Moduleable`, `ManageModuleRoute`, `ManageTraits` + +Foundation for all Modularous panel and API controllers. On construction it discovers the current module from the file path, resolves the repository, and loads module configuration — so subclasses always have `$moduleName`, `$modelName`, `$config`, and `$repository` available. + +## Constructor + +```php +public function __construct(Application $app, Request $request) +``` + +1. Stores `$app` and `$request`. +2. Calls trait hooks: `__beforeConstruct()` → `preload()` → `__afterConstruct()`. + +### Hook methods + +| Method | Purpose | +|--------|---------| +| `__beforeConstruct(...$args)` | Called before `preload()`. Traits may override this to run setup first. | +| `__afterConstruct(...$args)` | Called after `preload()`. Traits may override this to run setup last. | + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$app` | `Application` | Laravel service container | +| `$baseKey` | `string` | Module namespace key in snake_case | +| `$request` | `Request` | Current HTTP request | +| `$namespace` | `string` | Module PHP namespace | +| `$moduleName` | `string` | Discovered module name | +| `$modelName` | `string` | Model name (capitalised route name) | +| `$config` | `object` | Module configuration object | +| `$repository` | `Repository\|null` | Model repository instance | + +## Module Discovery + +### `getModuleName(): ?string` + +Discovers the module name by matching the controller's file path against the registered modules directories. Returns the first segment after the modules root, e.g. `app/Modules/Blog/...` → `Blog`. + +### `getNamespace(): string` + +Returns the PHP namespace for the current module (e.g. `Modules\Blog`). + +### `preload(): void` + +Loads `$moduleName`, `$modelName`, `$config`, `$routeName`, and `$repository`. Called automatically during construction. + +## Repository + +### `getRepository(): ?Repository` + +Initialises and returns the model repository. Resolves by convention — looks for `{Module}\Repositories\{Model}Repository` in the container. + +### `getRepositoryClass(string $model): ?string` + +Finds the repository class for a given model name. Returns `null` if none is registered. + +## Configuration + +### `getModuleConfig(): object` + +Returns the full configuration object for the current module route. + +### `getConfigFieldsByRoute(string $fieldName, $default = null): mixed` + +Gets a config field for the current route. Returns `$default` if absent. + +### `getConfigFieldsByRouteRaw(string $fieldName, $default = null): mixed` + +Same as above but bypasses any transformation — returns raw config values. + +## Route Utilities + +### `routeParameters(): array` + +Returns all parameters from the current route. + +### `routeArguments(): array` + +Returns route parameters merged with host-routing context (e.g. tenant ID). + +### `routeModuleArguments(): array` + +Converts route argument keys to StudlyCase — useful when passing arguments to module-aware methods. + +### `routeArgument(): mixed` + +Returns the argument value for the current route (first parameter). + +### `parentRouteArguments(): array` + +Returns only arguments that belong to parent routes (excludes current route parameter). + +### `routeHasTrait(string $behavior): bool` + +Returns `true` if the repository implements the given behaviour trait (e.g. `HasSoftDelete`, `HasRevisions`). + +### `routeHas(string $behavior): bool` + +Alias for `routeHasTrait`. + +## Tag & Assignment Endpoints + +These methods are called from generated module routes automatically. + +### `tags(): JsonResponse` + +Returns all available tags for the current model, used to populate tag input fields. + +### `tagsUpdate(): JsonResponse` + +Creates a new tag and returns it. Called when a user types a new tag value in the UI. + +### `assignments(int $id): JsonResponse` + +Returns the current assignments for a model instance. + +### `createAssignment(int $id): JsonResponse` + +Creates or updates an assignment with status and optional file attachments. diff --git a/docs/src/pages/system-reference/backend/http/controllers/currency-exchange-controller.md b/docs/src/pages/system-reference/backend/http/controllers/currency-exchange-controller.md new file mode 100644 index 000000000..5b11085b9 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/currency-exchange-controller.md @@ -0,0 +1,51 @@ +--- +sidebarPos: 8 +sidebarTitle: CurrencyExchangeController +--- + +# CurrencyExchangeController + +**File**: `src/Http/Controllers/CurrencyExchangeController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Illuminate\Routing\Controller` + +Manages currency exchange rates and performs currency conversions. Delegates all business logic to the injected `CurrencyExchangeService`. + +## Constructor + +```php +public function __construct(CurrencyExchangeService $service) +``` + +## Methods + +### `fetchRates(): JsonResponse` + +Fetches the latest exchange rates from the configured provider and persists them to the database. Typically called via a scheduled job or an admin-triggered action. + +### `convert(Request $request): JsonResponse` + +Converts an amount from the base currency to a target currency. + +**Validation**: + +| Field | Rules | Description | +|-------|-------|-------------| +| `amount` | required, numeric | Amount to convert | +| `currency` | required, size:3 | ISO 4217 target currency code | + +Returns the converted amount and the applied exchange rate. + +### `getRate(Request $request, string $currency): JsonResponse` + +Returns the current exchange rate for a specific currency. + +**Route parameter**: + +| Parameter | Rules | Description | +|-----------|-------|-------------| +| `$currency` | size:3 | ISO 4217 currency code | + +## Related + +- `CurrencyExchangeService` — underlying service that communicates with rate providers diff --git a/docs/src/pages/system-reference/backend/http/controllers/dashboard-controller.md b/docs/src/pages/system-reference/backend/http/controllers/dashboard-controller.md new file mode 100644 index 000000000..0a32f704c --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/dashboard-controller.md @@ -0,0 +1,64 @@ +--- +sidebarPos: 9 +sidebarTitle: DashboardController +--- + +# DashboardController + +**File**: `src/Http/Controllers/DashboardController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `BaseController` +**Traits**: `ManageUtilities`, `Allowable` + +Admin dashboard controller. Renders a grid of configurable block items, filtered by the authenticated user's roles. Supports both Blade and Inertia rendering. + +## Properties + +| Property | Value | Description | +|----------|-------|-------------| +| `$moduleName` | `'Dashboard'` | Fixed module name | +| `$routeName` | `'Dashboard'` | Fixed route name | + +## Constructor + +```php +public function __construct(Application $app, Request $request) +``` + +Removes the default `view` permission middleware (dashboard is accessible to all authenticated users) and replaces it with a `can:dashboard` gate check. + +## Methods + +### `index($parentId = null): View|Response` + +Renders the dashboard. Collects block items from `modularity.ui_settings.dashboard.blocks`, filters them by `allowedRoles` for the current user, renders each block's component, then passes the result to the view. + +**Passed to view**: + +| Variable | Description | +|----------|-------------| +| `blockItems` | Filtered and rendered dashboard blocks | +| `endpoints` | API endpoint map for dashboard components | +| `config` | Dashboard configuration object | + +When Inertia is active, delegates to `renderInertiaDashboard()`. + +### `renderInertiaDashboard(array $data): Response` + +Renders the dashboard using Inertia with shared store variables and metadata required by Vue dashboard components. + +## Block Configuration + +Blocks are defined in `config/modularity.php` under `ui_settings.dashboard.blocks`: + +```php +'blocks' => [ + [ + 'component' => 'StatCard', + 'allowedRoles' => ['admin', 'manager'], + 'props' => [ ... ], + ], +], +``` + +Each block's `allowedRoles` is checked against the current user's roles via the `Allowable` trait. Blocks with no `allowedRoles` key are visible to all users. diff --git a/docs/src/pages/system-reference/backend/http/controllers/file-library-controller.md b/docs/src/pages/system-reference/backend/http/controllers/file-library-controller.md new file mode 100644 index 000000000..6c09031cb --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/file-library-controller.md @@ -0,0 +1,107 @@ +--- +sidebarPos: 10 +sidebarTitle: FileLibraryController +--- + +# FileLibraryController + +**File**: `src/Http/Controllers/FileLibraryController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `BaseController` +**Implements**: `SignUploadListener` + +Manages the file library — uploading, listing, tagging, and (optionally) cloud-signing files. Supports local filesystem storage, Amazon S3, and Azure Blob Storage. + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$moduleName` | `string` | `'File'` | — | +| `$routeName` | `string` | `'File'` | — | +| `$routePrefix` | `string` | `'file-library'` | — | +| `$perPage` | `int` | `40` | Files per page | +| `$endpointType` | `string` | — | `'local'` or cloud provider key | +| `$defaultFilters` | `array` | `['search', 'tag', 'unused']` | Active filter keys | +| `$defaultOrders` | `array` | `[['column' => 'id', 'direction' => 'desc']]` | Default sort | + +## Methods + +### `index($parentId = null): array` + +Returns a paginated list of files with filtering by search term, tag, and unused status. + +### `getIndexData(array $scopes): array` + +Returns `['files', 'maxPage', 'total', 'tags']` — the raw data used by the index view. + +### `store($parentId = null): JsonResponse` + +Dispatches to `storeFile()` for local uploads or `storeReference()` for cloud references. + +### `storeFile(Request $request): File` + +Handles a local file upload: + +1. Sanitises the filename (whitespace → dashes). +2. Generates a UUID-based storage path with an optional local prefix. +3. Stores via the configured disk. +4. Persists the `File` record. + +### `storeReference(Request $request): File` + +Records a reference to a file already stored in cloud storage (bypasses local upload). + +### `singleUpdate(): JsonResponse` + +Updates tags for a single file. + +### `bulkUpdate(): JsonResponse` + +Updates tags for multiple files in one request. + +### `signS3Upload(Request $request, SignS3Upload $signer): mixed` + +Signs an S3 pre-signed policy for direct browser-to-S3 uploads. Delegates to the `SignS3Upload` action class. + +### `signAzureUpload(Request $request, SignAzureUpload $signer): mixed` + +Returns an Azure Blob Storage SAS URL for direct browser-to-Azure uploads. + +### `uploadIsSigned($signature, bool $isPublic = false): JsonResponse|Response` + +Called by `SignUploadListener` after a successful signing — returns the signed URL to the frontend. + +### `uploadIsNotValid(): JsonResponse` + +Called by `SignUploadListener` on signing failure. + +### `shouldReplaceFile($id): bool` + +Returns `true` when a file with the given ID exists, indicating the upload is a replacement. + +### `buildFile($item): array` + +Formats a `File` model into the shape expected by the frontend: + +```json +{ + "id": 1, + "name": "document.pdf", + "url": "https://...", + "tags": [...], + "mediableFormat": {...} +} +``` + +### `getRequestFilters(): array` + +Extracts `search`, `tag`, and `unused` from the request. + +## Cloud Storage + +Cloud signing is only triggered when `$endpointType` is not `'local'`. The controller implements `SignUploadListener` to handle the async result from the signing action classes. + +## Related + +- [MediaLibraryController](./media-library-controller) — image-specific variant with dimensions and alt text +- [FilepondController](./filepond-controller) — temporary upload handler used before library storage diff --git a/docs/src/pages/system-reference/backend/http/controllers/filepond-controller.md b/docs/src/pages/system-reference/backend/http/controllers/filepond-controller.md new file mode 100644 index 000000000..c430369b4 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/filepond-controller.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 11 +sidebarTitle: FilepondController +--- + +# FilepondController + +**File**: `src/Http/Controllers/FilepondController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `App\Http\Controllers\Controller` + +Handles the temporary file lifecycle for [FilePond](https://pqina.nl/filepond/) uploads. Files are held in a temporary location and either promoted to the media/file library on form submission or reverted (deleted) when the user cancels. + +## Constructor + +```php +public function __construct(FilepondManager $manager) +``` + +## Methods + +### `upload(Request $request): Response` + +Accepts a FilePond upload, stores the file in the configured temporary directory, and returns the temporary server ID string. The ID is what FilePond sends back to the server on form submission. + +### `revert(Request $request): Response` + +Deletes a previously uploaded temporary file. Called by FilePond when the user removes a file before submitting the form. + +### `preview(Request $request, string $folder): Response` + +Streams a temporary file for preview. The `$folder` parameter scopes the lookup to a specific upload folder, preventing path traversal. + +## Temporary File Flow + +``` +User picks file + └─ FilePond POSTs to /filepond/upload + └─ FilepondController::upload() saves to tmp/, returns server ID + └─ User submits form + ├─ Server resolves ID → permanent storage + └─ OR user cancels → FilePond DELETEs via revert() +``` + +## Related + +- [FileLibraryController](./file-library-controller) — promotes temporary files to the permanent file library +- [MediaLibraryController](./media-library-controller) — promotes temporary files to the media library +- [File Storage with FilePond](/guide/generics/file-storage-with-filepond) — integration guide diff --git a/docs/src/pages/system-reference/backend/http/controllers/front-controller.md b/docs/src/pages/system-reference/backend/http/controllers/front-controller.md new file mode 100644 index 000000000..12050701d --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/front-controller.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 12 +sidebarTitle: FrontController +--- + +# FrontController + +**File**: `src/Http/Controllers/FrontController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `CoreController` + +Abstract base controller for public-facing (non-authenticated) frontend routes within a Modularous module. + +Inherits all module discovery, repository, and route utilities from `CoreController` but does **not** apply the authentication or permission middleware that `PanelController` adds. + +## Usage + +Generate a front controller for a module by extending this class directly: + +```php +namespace Modules\Blog\Http\Controllers; + +use Unusualify\Modularity\Http\Controllers\FrontController; + +class PostController extends FrontController +{ + public function index() + { + $items = $this->repository->published()->paginate(); + + return view('blog::posts.index', compact('items')); + } +} +``` + +## Related + +- [CoreController](./core-controller) — base class providing `$repository`, `$config`, and module context +- [BaseController](./base-controller) — authenticated admin-panel variant diff --git a/docs/src/pages/system-reference/backend/http/controllers/glide-controller.md b/docs/src/pages/system-reference/backend/http/controllers/glide-controller.md new file mode 100644 index 000000000..80f760959 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/glide-controller.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 13 +sidebarTitle: GlideController +--- + +# GlideController + +**File**: `src/Http/Controllers/GlideController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` + +Single-action invokable controller that serves on-the-fly image transformations via the [Glide](https://glide.thephpleague.com/) library. + +## Signature + +```php +public function __invoke(string $path, Application $app): mixed +``` + +Accepts an image `$path` (relative to the configured source), applies transformation parameters from the query string, and streams the transformed image back to the browser. + +## Transformation Parameters + +Parameters are passed as query string values and processed by Glide: + +| Parameter | Example | Description | +|-----------|---------|-------------| +| `w` | `?w=300` | Width in pixels | +| `h` | `?h=200` | Height in pixels | +| `fit` | `?fit=crop` | Fit mode (`contain`, `max`, `fill`, `crop`) | +| `q` | `?q=80` | JPEG/WebP quality (1–100) | +| `fm` | `?fm=webp` | Output format | + +See the [Glide documentation](https://glide.thephpleague.com/2.0/api/quick-reference/) for the full parameter reference. + +## URL Signing + +Glide URLs are signed by default to prevent abuse. The signing key is taken from `modularity.glide.sign_key`. Requests with an invalid signature return a 403 response. + +## Related + +- [MediaLibraryController](./media-library-controller) — manages the source images served through Glide diff --git a/docs/src/pages/system-reference/backend/http/controllers/impersonate-controller.md b/docs/src/pages/system-reference/backend/http/controllers/impersonate-controller.md new file mode 100644 index 000000000..f87900148 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/impersonate-controller.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 14 +sidebarTitle: ImpersonateController +--- + +# ImpersonateController + +**File**: `src/Http/Controllers/ImpersonateController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Controller` + +Allows administrators to impersonate other users for debugging and support. The original admin session is preserved in the session so impersonation can be stopped at any time. + +## Constructor + +```php +public function __construct(AuthManager $auth) +``` + +## Methods + +### `impersonate(int $id, UserRepository $users): RedirectResponse` + +Starts impersonating the user with the given `$id`. + +1. Checks that the authenticated user has the `impersonate` permission (via `can:impersonate` gate). +2. Stores the original admin's ID in `session('modularity.impersonate.original_id')`. +3. Logs in as the target user via the modularity guard. +4. Redirects to the dashboard. + +### `stopImpersonate(): RedirectResponse` + +Ends the impersonation session. + +1. Reads the original admin ID from the session. +2. Restores the admin user via the modularity guard. +3. Removes the impersonation key from the session. +4. Redirects to the dashboard. + +## Permission + +The `impersonate` action is guarded by `can:impersonate`. Make sure this permission is assigned only to super-admin roles. + +## Related + +- [ImpersonateMiddleware](/system-reference/backend/http/middleware/impersonate) — middleware that injects the impersonation banner diff --git a/docs/src/pages/system-reference/backend/http/controllers/media-library-controller.md b/docs/src/pages/system-reference/backend/http/controllers/media-library-controller.md new file mode 100644 index 000000000..6894a8438 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/media-library-controller.md @@ -0,0 +1,104 @@ +--- +sidebarPos: 15 +sidebarTitle: MediaLibraryController +--- + +# MediaLibraryController + +**File**: `src/Http/Controllers/MediaLibraryController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `BaseController` +**Implements**: `SignUploadListener` + +Manages the media (image) library. Similar to `FileLibraryController` but stores image dimensions, alt text, captions, and custom metadata fields alongside each uploaded file. + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$moduleName` | `string` | `'Media'` | — | +| `$routeName` | `string` | `'Media'` | — | +| `$perPage` | `int` | `40` | Images per page | +| `$endpointType` | `string` | — | `'local'` or cloud provider key | +| `$customFields` | `array` | `[]` | Extra metadata field keys | +| `$defaultFilters` | `array` | `['search', 'tag', 'unused']` | Active filter keys | +| `$defaultOrders` | `array` | `[['column' => 'id', 'direction' => 'desc']]` | Default sort | + +## Methods + +### `index($parentId = null): array` + +Returns a paginated media list with tag counts and filter options. + +### `getIndexData(array $scopes): array` + +Returns `['items', 'maxPage', 'total', 'tags']`. + +### `store($parentId = null): JsonResponse` + +Dispatches to `storeFile()` for local uploads or `storeReference()` for cloud references. + +### `storeFile(Request $request): Media` + +Handles a local image upload: + +1. Extracts image dimensions via `getimagesize()`. +2. Sanitises filename and generates a UUID storage path. +3. Stores the file on the configured disk. +4. Persists the `Media` record with `width` and `height`. + +### `storeReference(Request $request): Media` + +Records a reference to an image stored in cloud storage. + +### `singleUpdate(): JsonResponse` + +Updates one media item's `alt_text`, `caption`, `tags`, and custom fields. + +### `bulkUpdate(): JsonResponse` + +Bulk-updates `alt_text`, `caption`, and tags across multiple media items. Supports a `remove` mode that clears a field from all selected items. + +### `bulkDelete(): JsonResponse` + +Soft-deletes multiple media items. + +### `signS3Upload(Request $request, SignS3Upload $signer): mixed` + +Signs an S3 pre-signed upload policy. + +### `signAzureUpload(Request $request, SignAzureUpload $signer): mixed` + +Returns an Azure Blob SAS URL. + +### `uploadIsSigned($signature, bool $isPublic = false): JsonResponse|Response` + +Returns the signed URL to the frontend on success. + +### `uploadIsNotValid(): JsonResponse` + +Returns an error on signing failure. + +### `shouldReplaceMedia($id): bool` + +Returns `true` when the given ID matches an existing media record (replacement flow). + +### `getExtraMetadatas(): Collection` + +Extracts values for `$customFields` from the current request and returns them as a keyed collection. + +## Custom Fields + +Define additional metadata fields per deployment by setting `$customFields` on a subclass: + +```php +protected array $customFields = ['photographer', 'license']; +``` + +These fields are extracted from the request in `getExtraMetadatas()` and persisted alongside the media record. + +## Related + +- [FileLibraryController](./file-library-controller) — non-image file variant +- [GlideController](./glide-controller) — serves transformed images from this library +- [FilepondController](./filepond-controller) — temporary upload handler diff --git a/docs/src/pages/system-reference/backend/http/controllers/metric-controller.md b/docs/src/pages/system-reference/backend/http/controllers/metric-controller.md new file mode 100644 index 000000000..23ce77c22 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/metric-controller.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 16 +sidebarTitle: MetricController +--- + +# MetricController + +**File**: `src/Http/Controllers/MetricController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Illuminate\Routing\Controller` + +Single-action invokable controller that resolves metric values for dashboard widgets. Supports connector-based metric providers and date-range filtering. + +## Signature + +```php +public function __invoke(Request $request): JsonResponse +``` + +## Request Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `items` | `array` | List of metric item definitions to resolve | +| `start_date` | `date` | Start of the reporting period (optional) | +| `end_date` | `date` | End of the reporting period (optional) | + +## Behaviour + +For each item in `items`: + +1. Resolves the connector class registered for the item type. +2. Injects date-range parameters into the connector if provided. +3. Updates any connector-specific parameters from the request. +4. Calls the connector to retrieve the metric value (which may be a callable). +5. Pushes the resolved value to a response collection. + +Returns all resolved metric values as a JSON array. + +## Connectors + +Connectors are classes registered in the module config that know how to retrieve a specific metric (e.g. total orders, active users). Each connector implements a common interface that accepts optional date and parameter overrides. + +Callable metric values allow connectors to return lazy-evaluated data — the value is only computed when the controller invokes it. diff --git a/docs/src/pages/system-reference/backend/http/controllers/overview.md b/docs/src/pages/system-reference/backend/http/controllers/overview.md new file mode 100644 index 000000000..3a32c1ccd --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/overview.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 1 +sidebarTitle: Controllers Overview +--- + +# Controllers + +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Location**: `src/Http/Controllers/` + +All HTTP controllers in the Modularous package. They form a four-level hierarchy rooted at `Controller`, with specialized controllers for admin panel, REST APIs, authentication, and utility endpoints. + +## Inheritance Hierarchy + +``` +Illuminate\Routing\Controller +└── Controller + ├── CoreController + │ ├── PanelController + │ │ └── BaseController ← standard CRUD admin controllers + │ ├── ApiController ← REST API base + │ └── FrontController ← public-facing frontend base + └── (Auth\Controller → Auth\LoginController, RegisterController, …) +``` + +## Base Classes + +| Class | File | Purpose | +|-------|------|---------| +| [Controller](./controller) | `Controller.php` | Root controller — middleware utilities, exception handler binding | +| [CoreController](./core-controller) | `CoreController.php` | Module discovery, repository init, route config | +| [PanelController](./panel-controller) | `PanelController.php` | Authorization, scoping, pagination for admin panel | +| [BaseController](./base-controller) | `BaseController.php` | Full CRUD (index/create/store/edit/update/destroy) + Inertia support | +| [ApiController](./api-controller) | `ApiController.php` | REST API base — versioning, rate-limiting, includes, bulk ops | +| [FrontController](./front-controller) | `FrontController.php` | Abstract base for public-facing routes | + +## Specialized Controllers + +| Class | File | Purpose | +|-------|------|---------| +| [ChatController](./chat-controller) | `ChatController.php` | Chat messages, attachments, pinned messages | +| [CurrencyExchangeController](./currency-exchange-controller) | `CurrencyExchangeController.php` | Exchange rates and currency conversion | +| [DashboardController](./dashboard-controller) | `DashboardController.php` | Admin dashboard with configurable block items | +| [FileLibraryController](./file-library-controller) | `FileLibraryController.php` | File uploads, local and cloud (S3/Azure) | +| [FilepondController](./filepond-controller) | `FilepondController.php` | Temporary Filepond upload/revert/preview | +| [GlideController](./glide-controller) | `GlideController.php` | On-the-fly image transformation via Glide | +| [ImpersonateController](./impersonate-controller) | `ImpersonateController.php` | Admin user impersonation | +| [MediaLibraryController](./media-library-controller) | `MediaLibraryController.php` | Image uploads with dimensions, alt text, captions | +| [MetricController](./metric-controller) | `MetricController.php` | Metric items with connectors and date-range filtering | +| [PasswordController](./password-controller) | `PasswordController.php` | Password reset and generation | +| [ProcessController](./process-controller) | `ProcessController.php` | Process workflow status and field updates | +| [ProfileController](./profile-controller) | `ProfileController.php` | User profile — info, security, company | +| [TagController](./tag-controller) | `TagController.php` | Tag search and creation | +| [UIPreferencesController](./ui-preferences-controller) | `UIPreferencesController.php` | Persist sidebar/topbar/navigation preferences | +| [VerificationController](./verification-controller) | `VerificationController.php` | Email address verification | + +## Authentication Controllers + +All under `src/Http/Controllers/Auth/` (namespace `…\Auth`). + +| Class | File | Purpose | +|-------|------|---------| +| [Auth\Controller](./auth/controller) | `Auth/Controller.php` | Base for auth workflows, guest middleware | +| [LoginController](./auth/login-controller) | `Auth/LoginController.php` | Login form + 2FA | +| [RegisterController](./auth/register-controller) | `Auth/RegisterController.php` | User registration with optional company | +| [ForgotPasswordController](./auth/forgot-password-controller) | `Auth/ForgotPasswordController.php` | Send password-reset email | +| [PreRegisterController](./auth/pre-register-controller) | `Auth/PreRegisterController.php` | Email verification before registration | +| [CompleteRegisterController](./auth/complete-register-controller) | `Auth/CompleteRegisterController.php` | Finish registration after token validation | +| [ResetPasswordController](./auth/reset-password-controller) | `Auth/ResetPasswordController.php` | Password reset after token validation | + +## API Controllers + +Under `src/Http/Controllers/API/`. + +| Class | File | Purpose | +|-------|------|---------| +| [API\LanguageController](./api-language-controller) | `API/LanguageController.php` | Serve translation strings via JSON API | diff --git a/docs/src/pages/system-reference/backend/http/controllers/panel-controller.md b/docs/src/pages/system-reference/backend/http/controllers/panel-controller.md new file mode 100644 index 000000000..959f3c080 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/panel-controller.md @@ -0,0 +1,146 @@ +--- +sidebarPos: 17 +sidebarTitle: PanelController +--- + +# PanelController + +**File**: `src/Http/Controllers/PanelController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `CoreController` +**Implements**: `CacheableInterface` +**Traits**: `MakesResponses`, `ManageScopes`, `ManageAuthorization`, `CacheableResponse`, `ManageWiths`, `ManageAppends` + +Abstract controller for authenticated admin-panel routes. Adds permission-based middleware, scope handling, nested route detection, and paginated index fetching on top of `CoreController`. + +All generated module admin controllers extend `BaseController` which extends this class. + +## Constructor + +```php +public function __construct(Application $app, Request $request) +``` + +1. Applies `auth.modularity` and `verified` middleware. +2. Calls `preload()` (which triggers `CoreController::preload()` and adds nested/scope setup). +3. Calls `setMiddlewarePermission()` if `$setDefaultPermissions` is `true`. + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$user` | `Model` | — | Authenticated user | +| `$routePrefix` | `string` | — | Route prefix for URL generation | +| `$isParent` | `bool` | — | Whether current route is a parent | +| `$isNested` | `bool` | `false` | Whether current route is nested | +| `$nestedParentId` | `int` | — | Parent record ID when nested | +| `$nestedParentName` | `string` | — | Parent route name when nested | +| `$nestedParentModel` | `Model` | — | Parent model instance when nested | +| `$modelTitle` | `string` | — | Human-readable model title | +| `$defaultIndexOptions` | `array` | See below | Default CRUD visibility flags | +| `$indexOptions` | `array` | `[]` | Overridable CRUD visibility flags | +| `$perPage` | `int` | `10` | Items per page | +| `$titleColumnKey` | `string` | `'name'` | Column used as model title | +| `$setDefaultPermissions` | `bool` | `true` | Register permission middleware automatically | + +### Default index options + +```php +[ + 'create' => true, + 'edit' => true, + 'delete' => true, + 'restore' => false, + 'forceDelete' => false, + 'duplicate' => false, + 'reorder' => false, + 'publish' => false, +] +``` + +Override per-controller by setting `$indexOptions`. + +## Permissions + +### `setMiddlewarePermission(): void` + +Registers middleware for each CRUD action: + +| Action | Permission checked | +|--------|--------------------| +| `index`, `create`, `store` | `{routeName}.view` | +| `edit`, `update` | `{routeName}.edit` | +| `destroy`, `forceDelete`, `bulkDelete` | `{routeName}.delete` | +| `restore`, `bulkRestore` | `{routeName}.restore` | + +### `permissionPrefix(string $permission): string` + +Generates a dot-separated permission name, e.g. `blog.posts.edit`. + +### `isGateable(): bool` + +Returns `true` when permission gates are enabled for the current module. + +## Nested Routes + +### `checkNestedAttributes(): void` + +Detects whether the current route is nested and populates `$isNested`, `$nestedParentId`, `$nestedParentName`, `$nestedParentModel`. + +### `getNestedAttributes(): array` + +Returns `[isNested, parentId, parentName, parentModel]`. + +### `getParentModuleForeignKey(): string` + +Returns the foreign key column name pointing to the parent (e.g. `blog_id`). + +### `nestedParentScopes(): array` + +Returns query scopes that limit results to the current parent record. + +## Index Helpers + +### `getIndexItems(array $scopes, array $orders, array $filters, bool $forcePaginate): Paginator` + +Fetches a paginated set of items using the repository. Applies scopes, ordering, and filters. + +### `transformIndexItems(Collection $items): Collection` + +Hook called after fetching index items. Override in subclasses to transform results before serialisation. + +### `getFormattedIndexItems(Paginator $items): array` + +Formats paginator results into the array structure expected by the frontend. + +### `getJSONData(array $data): mixed` + +Returns JSON-formatted index data with pagination meta. + +## Form Helpers + +### `validateFormRequest(array $formData): Request` + +Validates form data respecting `$fieldsPermissions` — strips fields the current user is not allowed to write. + +### `getFormRequestClass(array $formData): Request` + +Resolves or instantiates the correct `FormRequest` class for the current module route. + +## Route Helpers + +### `getRoutePrefix(): string` + +Returns the current route prefix string. + +### `generateRoutePrefix(bool $withParent = true): string` + +Builds the route prefix from module and route names, optionally including parent segments. + +### `getModuleRoute(int $id, string $action, bool $absolute): string` + +Generates a URL for a module action (e.g. edit, destroy) for a given record ID. + +### `getReplaceUrl(): bool` + +Returns `true` when the browser URL should be replaced (not pushed) after navigation. diff --git a/docs/src/pages/system-reference/backend/http/controllers/password-controller.md b/docs/src/pages/system-reference/backend/http/controllers/password-controller.md new file mode 100644 index 000000000..76a9ed711 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/password-controller.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 18 +sidebarTitle: PasswordController +--- + +# PasswordController + +**File**: `src/Http/Controllers/PasswordController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Controller` +**Traits**: `ManageUtilities`, `MakesResponses`, `ResetsPasswords` +**Middleware**: `modularity.guest` + +Handles two distinct password flows: the **forgot-password reset** flow (user already has an account) and the **first-time password generation** flow (new user completing registration). + +## Constructor + +```php +public function __construct() +``` + +Applies the `modularity.guest` middleware to all actions — only unauthenticated users may access these routes. + +## Methods + +### `showForm(Request $request, string $token): View` + +Displays the password entry form. The `$token` is validated against the password reset table before the form is shown. The form schema is loaded from `getFormDraft('reset_password_form')`. + +Used for both the forgot-password link (from email) and the welcome link (new user invitation). + +### `savePassword(Request $request): JsonResponse|RedirectResponse` + +Validates the submitted password (with confirmation) and calls `resetPassword()`. + +On success: +- Marks the user's email as verified. +- Logs the user in via the modularity guard. +- Returns JSON for Inertia requests or redirects for traditional requests. + +### `broker(): PasswordBroker` + +Returns the Laravel password broker configured for Modularous. + +### `guard(): StatefulGuard` + +Returns the modularity authentication guard. + +### `resetPassword($user, string $password): void` + +Sets the user's password, saves the model, and logs the user in. + +## Related + +- [Auth\ForgotPasswordController](./auth/forgot-password-controller) — sends the reset email +- [Auth\ResetPasswordController](./auth/reset-password-controller) — the standard Laravel reset flow diff --git a/docs/src/pages/system-reference/backend/http/controllers/process-controller.md b/docs/src/pages/system-reference/backend/http/controllers/process-controller.md new file mode 100644 index 000000000..e930768eb --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/process-controller.md @@ -0,0 +1,51 @@ +--- +sidebarPos: 19 +sidebarTitle: ProcessController +--- + +# ProcessController + +**File**: `src/Http/Controllers/ProcessController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Illuminate\Routing\Controller` + +Manages process workflow instances — retrieving their state with optional eager-loaded relationships and updating their status, reason, or processable-model fields. + +## Methods + +### `show(Request $request, Process $process): JsonResponse` + +Returns a process and its relationships. + +**Request parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `with` | `array` | Relationship names to eager-load | + +Only relationships defined in the process model's `$eagerLoadable` property are permitted. Invalid relationships are silently ignored. + +### `update(Request $request, Process $process): JsonResponse` + +Updates a process. Two modes: + +**Status/reason update** — when `status` or `reason` is present: + +| Field | Description | +|-------|-------------| +| `status` | New process status value | +| `reason` | Optional reason text | + +**Processable field update** — when the request contains fields from the processable model's schema: + +Fields are extracted by matching request keys against the processable model's form schema. Only schema-declared fields are written; all others are rejected. + +Non-eager-loadable relations are loaded via `$process->load()` after the update so the response always contains fresh relationship data. + +## Process Model + +The `Process` model represents a state-machine instance attached to a `processable` morphable model (e.g. an Order, a Subscription). The processable model's fields can be updated directly through this controller when the process is in a state that permits editing. + +## Related + +- `HasProcess` entity trait — adds process management to any model diff --git a/docs/src/pages/system-reference/backend/http/controllers/profile-controller.md b/docs/src/pages/system-reference/backend/http/controllers/profile-controller.md new file mode 100644 index 000000000..2632d09f2 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/profile-controller.md @@ -0,0 +1,78 @@ +--- +sidebarPos: 20 +sidebarTitle: ProfileController +--- + +# ProfileController + +**File**: `src/Http/Controllers/ProfileController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `BaseController` +**Traits**: `ManageUtilities` + +User profile management controller. Provides three editing sections — personal info, security (password), and company/billing — with optional email verification status display. + +## Properties + +| Property | Value | Description | +|----------|-------|-------------| +| `$namespace` | `'Modules\SystemUser'` | Fixed to the SystemUser module | +| `$moduleName` | `'Profile'` | — | +| `$routeName` | `'Profile'` | — | +| `$modelName` | `'User'` | — | + +## Constructor + +```php +public function __construct( + Application $app, + Request $request, + UserRepository $userRepository, + CompanyRepository $companyRepository +) +``` + +Removes the default `view` and `edit` permission middleware — profile pages are accessible to any authenticated user without extra permissions. + +## Methods + +### `edit($id = null, $submoduleId = null): View|Response` + +Displays the profile edit page with three form sections: + +| Section | Fields | +|---------|--------| +| **User info** | name, surname, email, avatar, locale | +| **Security** | current password, new password, confirmation | +| **Company** | company name, tax ID, address, billing details | + +When the user's email is unverified, a "Verify Email" button is injected into the user info form. The company form section is hidden if `modularity.lock_company_edit` is `true`. + +Delegates to `renderInertiaProfile()` when Inertia is active. + +### `renderInertiaProfile(array $data): Response` + +Renders the profile via Inertia, passing store variables, form sections, and verification status. + +### `update($id = null, $submoduleId = null): JsonResponse` + +Handles all three section submissions. Detects which section was submitted from the request payload and dispatches accordingly: + +- **User info**: updates name, surname, locale, and avatar. +- **Security**: validates current password before updating. +- **Profile-level fields**: updates any other declared profile fields. + +Logs an `updated` activity event on success. + +### `display(): View|JsonResponse` + +Returns the authenticated user's profile data (for the profile display page, not the edit form). + +### `updateCompany(CompanyRequest $request): JsonResponse` + +Updates the user's associated company billing information. Validates through `CompanyRequest`. + +## Related + +- `UserRepository` — data access for user records +- `CompanyRepository` — data access for company/billing records diff --git a/docs/src/pages/system-reference/backend/http/controllers/tag-controller.md b/docs/src/pages/system-reference/backend/http/controllers/tag-controller.md new file mode 100644 index 000000000..1fb964071 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/tag-controller.md @@ -0,0 +1,50 @@ +--- +sidebarPos: 21 +sidebarTitle: TagController +--- + +# TagController + +**File**: `src/Http/Controllers/TagController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Illuminate\Routing\Controller` + +Provides tag search and creation endpoints used by taggable input components across the admin panel. + +## Methods + +### `index(Request $request): JsonResponse` + +Searches existing tags for a given taggable model. + +**Request parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `q` | `string` | Search query (partial name match) | +| `taggable` | `string` | Fully qualified model class (e.g. `Modules\Blog\Models\Post`) | + +Returns a list of matching tags for the specified model type. + +### `update(Request $request): JsonResponse` + +Creates a new tag or retrieves an existing one by value. + +**Request parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `value` | `string` | Tag display name | +| `taggable` | `string` | Fully qualified model class | +| `locale` | `string` | Locale for the tag name (optional) | + +Generates a slug via the tag model's slug method. Returns the tag record (created or found). + +## Usage in Forms + +Tag input fields in the admin panel call `index` for autocomplete and `update` when the user types a new tag value that doesn't exist yet. The `taggable` parameter scopes tags to the specific model so different models don't share tag namespaces unless intentionally configured. + +## Related + +- `HasTags` entity trait — adds tagging support to a model +- `CoreController::tags()` / `tagsUpdate()` — module-specific tag endpoints that delegate to this controller diff --git a/docs/src/pages/system-reference/backend/http/controllers/ui-preferences-controller.md b/docs/src/pages/system-reference/backend/http/controllers/ui-preferences-controller.md new file mode 100644 index 000000000..600dd5b1b --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/ui-preferences-controller.md @@ -0,0 +1,51 @@ +--- +sidebarPos: 22 +sidebarTitle: UIPreferencesController +--- + +# UIPreferencesController + +**File**: `src/Http/Controllers/UIPreferencesController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Controller` +**Traits**: `MakesResponses` + +Persists per-user UI preferences (sidebar state, topbar visibility, bottom navigation) to the database. A whitelist ensures only known preference keys can be written. + +## Methods + +### `update(Request $request): JsonResponse` + +Merges the submitted preferences into the current user's stored preferences and saves them. + +Only keys defined in the whitelist (see below) are accepted. Unknown keys are silently discarded by `filterAllowedPreferences()`. + +### `filterAllowedPreferences(array $data): array` + +Filters the input array against the whitelist and returns only allowed key–value pairs. + +## Allowed Preference Keys + +| Scope | Allowed keys | +|-------|-------------| +| `sidebar` | `rail`, `location`, `width`, `expandOnHover`, `hideIcons`, `pinned`, `status` | +| `topbar` | `enabled`, `fixed`, `order`, `showOnMobile`, `showOnDesktop` | +| `bottomNavigation` | `enabled`, `showOnMobile`, `showOnDesktop` | + +Preferences are stored on the user model and loaded into the frontend store on every page load. + +## Example Request + +```json +{ + "sidebar": { + "rail": true, + "pinned": false + }, + "topbar": { + "enabled": true + } +} +``` + +Submitted keys not present in the whitelist are dropped before saving. diff --git a/docs/src/pages/system-reference/backend/http/controllers/verification-controller.md b/docs/src/pages/system-reference/backend/http/controllers/verification-controller.md new file mode 100644 index 000000000..d1a1e0eb5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/controllers/verification-controller.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 23 +sidebarTitle: VerificationController +--- + +# VerificationController + +**File**: `src/Http/Controllers/VerificationController.php` +**Namespace**: `Unusualify\Modularity\Http\Controllers` +**Extends**: `Controller` +**Traits**: `ManageForm` + +Handles email address verification for authenticated users. Provides an endpoint to fulfil a signed verification link and another to re-send the verification email. + +## Methods + +### `verify(EmailVerificationRequest $request): View` + +Fulfils an email verification request. Uses Laravel's built-in `EmailVerificationRequest` which validates the signature and user ID automatically. + +On success, renders a success view with the following state: + +| Variable | Value | +|----------|-------| +| `status` | `'success'` | +| `title` | Verification complete message | +| `description` | Verification complete description | +| `button_url` | Dashboard route | + +### `send(Request $request): RedirectResponse` + +Re-sends the email verification notification to the currently authenticated user. Calls `sendEmailVerificationNotification()` on the user model and redirects back with a flash message. + +## Verification Flow + +``` +User clicks "Verify Email" (e.g. from ProfileController) + └─ Server sends signed verification link via email + └─ User clicks link + └─ verify() validates signature and marks email as verified + └─ Success view displayed with link back to dashboard +``` + +## Related + +- [ProfileController](./profile-controller) — shows the "Verify Email" button when email is unverified +- [Auth\RegisterController](./auth/register-controller) — triggers initial verification email on registration diff --git a/docs/src/pages/system-reference/backend/http/middleware/authenticate.md b/docs/src/pages/system-reference/backend/http/middleware/authenticate.md new file mode 100644 index 000000000..f67e4b119 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/authenticate.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 2 +sidebarTitle: AuthenticateMiddleware +--- + +# AuthenticateMiddleware + +**File**: `src/Http/Middleware/AuthenticateMiddleware.php` +**Alias**: `modularity.auth` +**Extends**: `Illuminate\Auth\Middleware\Authenticate` + +Extends Laravel's built-in `Authenticate` middleware with two Modularous-specific behaviours: intended-URL preservation before the redirect and JSON 401 handling for Inertia/AJAX requests. + +## Behaviour + +### Standard HTML requests + +When an unauthenticated browser request hits a protected route: + +1. The current URL is saved to `session('url.intended')` via `session()->put('url.intended', url()->previous())`. +2. The user is redirected to the admin login route (`admin.login.form`). + +**Excluded routes** — the intended URL is NOT stored when the current route is any of: + +| Route name (prefixed) | +|-----------------------| +| `login.form`, `login`, `logout` | +| `register.form`, `register`, `register.success` | +| `password.reset.*` | +| `impersonate`, `impersonate.stop` | + +This prevents the login page itself (or other auth pages) from being set as the post-login destination. + +### JSON / Inertia requests + +When the request expects JSON (`Accept: application/json`): + +- The `Referer` header is stored in `session('url.intended')`. +- Returns a `401 JSON` response: + ```json + { "message": "Unauthenticated.", "mode": "experimental" } + ``` + +## Usage + +Applied via the `web.auth` and `api.auth` middleware groups: + +```php +Route::middlewareGroup('web.auth', [ + 'web', + 'modularity.auth:modularity', +]); +``` + +The guard name is injected from `Modularity::getAuthGuardName()` (default: `'modularity'`). + +## Notes + +- After a successful login, the `RedirectService` or Laravel's `url()->intended()` uses the stored URL to redirect the user back. +- The `mode: experimental` field in the JSON 401 is a placeholder for future Inertia partial-reload handling. diff --git a/docs/src/pages/system-reference/backend/http/middleware/authorization.md b/docs/src/pages/system-reference/backend/http/middleware/authorization.md new file mode 100644 index 000000000..478142b3b --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/authorization.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 3 +sidebarTitle: AuthorizationMiddleware +--- + +# AuthorizationMiddleware + +**File**: `src/Http/Middleware/AuthorizationMiddleware.php` +**Alias**: `authorization` +**Part of**: `modularity.panel` group + +Shares authorization-related view data with the Blade master layout. Runs only on authenticated panel routes. + +## What It Does + +Registers a view composer for `modularity::layouts.master`: + +```php +view()->composer('modularity::layouts.master', function ($view) { + $view->with([ + 'authorization' => get_modularity_authorization_config(), + 'profileShortcutSchema' => $profileShortcutSchema, + 'profileShortcutModel' => $profileShortcutModel, + 'loginShortcutSchema' => $loginShortcutSchema, + 'loginShortcutModel' => [], + ]); +}); +``` + +| Variable | Source | Description | +|----------|--------|-------------| +| `authorization` | `get_modularity_authorization_config()` | Role/permission config used to control UI visibility | +| `profileShortcutSchema` | `getFormDraft('profile_shortcut')` | Form schema for the profile quick-edit panel | +| `profileShortcutModel` | `UserRepository::getFormFields($user, $schema)` | Current user's profile field values | +| `loginShortcutSchema` | `getFormDraft('login_shortcut')` | Form schema for the login shortcut widget | +| `loginShortcutModel` | `[]` | Empty — populated client-side | + +## Notes + +- This middleware only affects Blade-rendered pages. For Inertia pages, authorization data is shared via `HandleInertiaRequests::share()` under the `authorization` prop. +- The `UserRepository` is resolved fresh on each request to ensure the profile model reflects the current authenticated state (including impersonation). diff --git a/docs/src/pages/system-reference/backend/http/middleware/company-registration.md b/docs/src/pages/system-reference/backend/http/middleware/company-registration.md new file mode 100644 index 000000000..2124081bd --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/company-registration.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 4 +sidebarTitle: CompanyRegistrationMiddleware +--- + +# CompanyRegistrationMiddleware + +**File**: `src/Http/Middleware/CompanyRegistrationMiddleware.php` +**Alias**: `modularity.company.registration` +**Part of**: `modularity.panel` group + +Guards panel routes that require the authenticated user to have a valid company record. Currently a stub — the enforcement logic is commented out pending the company validation feature. + +## Current Behaviour + +The middleware passes all requests through without restriction: + +```php +public function handle($request, Closure $next) +{ + return $next($request); +} +``` + +## Intended Behaviour (stub) + +The commented-out block shows the planned enforcement: + +```php +// if (! $request->routeIs('*profile*')) { +// if (!auth()->user()->validCompany) { +// return redirect()->route(Route::hasAdmin('profile')); +// } +// } +``` + +When activated, this will: +- Skip the check for any route matching `*profile*` (to avoid redirect loops). +- Redirect users without a `validCompany` to the profile page to complete their registration. + +## Notes + +- The middleware is already registered in the `modularity.panel` group and will enforce the company check automatically once the logic is uncommented. +- `validCompany` is expected to be a computed attribute or relationship check on the User model. diff --git a/docs/src/pages/system-reference/backend/http/middleware/handle-inertia-requests.md b/docs/src/pages/system-reference/backend/http/middleware/handle-inertia-requests.md new file mode 100644 index 000000000..434ac5fa4 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/handle-inertia-requests.md @@ -0,0 +1,90 @@ +--- +sidebarPos: 5 +sidebarTitle: HandleInertiaRequests +--- + +# HandleInertiaRequests + +**File**: `src/Http/Middleware/HandleInertiaRequests.php` +**Alias**: `inertia.middleware` (singleton) +**Extends**: `Inertia\Middleware` + +The central Inertia middleware that establishes the root Blade view and populates the shared props passed to every Vue page component on every request. + +## Root View + +```php +protected $rootView = 'modularity::layouts.app-inertia'; +``` + +## Shared Props + +All props returned by `share()` are available in every Vue page via `usePage().props`: + +### `auth` + +```js +{ user: { ...userObject } } +``` + +### `flash` + +Lazy-loaded flash messages from the session: + +```js +{ message: '...', success: '...', error: '...' } +``` + +### `config` + +```js +{ app_name: '...', js_namespace: '...', timezone: '...' } +``` + +### `endpoints` + +Custom per-request endpoint map set via `$request->attributes->set('endpoints', [...])` in controllers. + +### `authorization` + +Resolved per request from the authenticated user: + +| Key | Type | Description | +|-----|------|-------------| +| `isSuperAdmin` | bool | `$user->is_superadmin` | +| `isClient` | bool | `$user->isClient()` | +| `is_client` | bool | `$user->is_client` | +| `hasRestorable` | bool | Soft-delete restore capability | +| `hasBulkable` | bool | Bulk action capability | +| `permissions` | string[] | All permission names | +| `roles` | string[] | All role names | + +Returns `[]` for unauthenticated requests. + +### `storeData` + +Initialisation data for the Pinia/Vuex store, split into sub-objects: + +| Key | Contents | +|-----|----------| +| `config` | Sidebar, topbar, bottomNav options; UI preferences; endpoints | +| `user` | Profile, routes, shortcut schemas | +| `medias` | Media library types (images + files), crop config | +| `languages` | All languages, active language | +| `form` | Base URL, initial inputs | +| `datatable` | Advanced filters, custom modal flag | +| `ambient` | Environment, app name/email/debug, package versions | + +## Media Types + +`getMediaTypes()` adds entries for enabled libraries: + +| Module flag | Entry added | +|-------------|------------| +| `enabled.media-library` | Images type with `media-library.media.index` endpoint | +| `enabled.file-library` | Files type with `file-library.file.index` endpoint | + +## Notes + +- `version()` delegates to the parent Inertia middleware, which hashes the `mix-manifest.json` or Vite manifest for asset versioning. +- `getLanguages()` and `getActiveLanguage()` return empty arrays by default — override in an app-level middleware extension to plug in your translation/locale system. diff --git a/docs/src/pages/system-reference/backend/http/middleware/hostable.md b/docs/src/pages/system-reference/backend/http/middleware/hostable.md new file mode 100644 index 000000000..5da5d4328 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/hostable.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 6 +sidebarTitle: HostableMiddleware +--- + +# HostableMiddleware + +**File**: `src/Http/Middleware/HostableMiddleware.php` +**Alias**: `hostable` + +Stub middleware reserved for host-based routing features. Currently passes all requests through unchanged. + +## Current Behaviour + +```php +public function handle($request, Closure $next) +{ + return $next($request); +} +``` + +## Intended Use + +The `hostable` alias is an optional middleware that can be applied to routes that should only be accessible from specific hosts or subdomains. Apply it selectively to routes that need host-based restrictions: + +```php +Route::middleware('hostable')->group(function () { + Route::get('/tenant-dashboard', TenantController::class); +}); +``` + +## Notes + +- Unlike the core middleware group, `hostable` is **not** applied automatically. It must be added explicitly to routes that need it. +- The `HostRouteRegistrar` in `src/Support/` works in conjunction with this middleware for multi-tenant subdomain routing scenarios. diff --git a/docs/src/pages/system-reference/backend/http/middleware/impersonate.md b/docs/src/pages/system-reference/backend/http/middleware/impersonate.md new file mode 100644 index 000000000..f66ffbd4d --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/impersonate.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 7 +sidebarTitle: ImpersonateMiddleware +--- + +# ImpersonateMiddleware + +**File**: `src/Http/Middleware/ImpersonateMiddleware.php` +**Alias**: `modularity.impersonate` +**Part of**: `modularity.core` group + +Activates user impersonation for the current request and shares the impersonation config with the master Blade layout. + +## How Impersonation Works + +When an admin starts impersonating another user, the target user's ID is stored in the session under the `'impersonate'` key. On every subsequent request this middleware: + +1. Checks for `session('impersonate')`. +2. If present, calls `auth()->guard(Modularity::getAuthGuardName())->onceUsingId($targetId)` — this swaps the authenticated user **for the current request only**, without affecting the session's real authenticated user. +3. Composes `modularity::layouts.master` with the impersonation config (`get_modularity_impersonation_config()`), which provides the frontend with the data needed to render the "stop impersonating" banner. + +## Impersonation Config + +`get_modularity_impersonation_config()` returns an array used by the admin panel to: + +- Show or hide the impersonation banner. +- Provide the "stop impersonating" route. +- Display the impersonated user's name. + +## Starting / Stopping Impersonation + +Impersonation is controlled by two routes: + +| Route | Action | +|-------|--------| +| `admin.impersonate` | Sets `session('impersonate', $userId)` | +| `admin.impersonate.stop` | Removes `session('impersonate')` | + +These routes are excluded from the intended-URL store in `AuthenticateMiddleware`. + +## Notes + +- `onceUsingId()` is request-scoped — the real session user is never changed. If the browser is closed and reopened, the impersonation is gone. +- Superadmin-level permission check before setting the session key should be enforced at the controller level, not in this middleware. diff --git a/docs/src/pages/system-reference/backend/http/middleware/language.md b/docs/src/pages/system-reference/backend/http/middleware/language.md new file mode 100644 index 000000000..bd272cd75 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/language.md @@ -0,0 +1,61 @@ +--- +sidebarPos: 8 +sidebarTitle: LanguageMiddleware +--- + +# LanguageMiddleware + +**File**: `src/Http/Middleware/LanguageMiddleware.php` +**Alias**: `modularity.language` +**Part of**: `modularity.core` group + +Resolves and applies the active locale, timezone, and currency for the current request. Runs on every route that uses the `modularity.core` middleware group. + +## Locale Resolution Priority + +The locale is determined in this order: + +1. **`?language=` query parameter** — explicit override, takes highest priority. +2. **Authenticated user's `language` property** — `$request->user()->language`. +3. **GeoIP auto-detection** — only when `MODULARITY_AUTO_LOCALE_FINDER=true` in `.env`; uses `geoip()->getLocation($ip)->iso_code`. The resolved code must be in `modularity.available_user_locales` or it is ignored. +4. **App default locale** — `app()->getLocale()` fallback. + +> **Translation route exception**: When the current route is `languages.translations.index`, the locale is forced back to the fallback locale regardless of the resolved value. + +## What Gets Set + +| Config / setting | Value | +|-----------------|-------| +| `modularity.locale` | Resolved locale code | +| `modularity.timezone` | `auth()->user()->timezone` or `'Europe/London'` | +| `app.locale` | Set via `App::setLocale()` | +| `app.fallback_locale` | Set via `App::setFallbackLocale()` | +| Carbon locale | `CarbonInterval::setLocale()` + `Carbon::setLocale()` | + +## Currency Resolution + +After the locale is set, the middleware determines the active currency: + +1. If `services.currency_exchange.active` is **false** (single-currency mode): looks up `payment.locale_currencies.{locale}` in config; falls back to `priceable.currency`. +2. If the resolved currency differs from the current `priceable.currency`, updates `config(['priceable.currency' => $currency])` and calls `CurrencyProviderInterface::findByIso4217()` to attach the currency model to the request. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MODULARITY_AUTO_LOCALE_FINDER` | `false` | Enable GeoIP-based locale detection | + +## Configuration + +```php +// config/modularity.php +'available_user_locales' => ['en', 'tr', 'de'], +'fallback_locale' => 'en', + +'payment' => [ + 'locale_currencies' => [ + 'tr' => 'TRY', + 'de' => 'EUR', + ], +], +``` diff --git a/docs/src/pages/system-reference/backend/http/middleware/load-localized-config.md b/docs/src/pages/system-reference/backend/http/middleware/load-localized-config.md new file mode 100644 index 000000000..18f1bb185 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/load-localized-config.md @@ -0,0 +1,50 @@ +--- +sidebarPos: 9 +sidebarTitle: LoadLocalizedConfig +--- + +# LoadLocalizedConfig + +**File**: `src/Http/Middleware/LoadLocalizedConfig.php` +**Alias**: `modularity.loadLocalizedConfig` +**Part of**: `modularity.core` group + +Merges deferred and application-level config files into the live `modularity.*` config at request time. This allows config to be split across multiple files and overridden per-application without touching the published package config. + +## What It Does + +### 1. Merge vendor deferred configs + +Scans `{vendor_path}/config/defers/*.php` and merges each file into `modularity.{filename}`: + +```php +foreach (glob(Modularity::getVendorPath('config/defers/*.php')) as $path) { + mergeConfigFrom($path, 'modularity.{filename}'); +} +``` + +### 2. Merge application-level overrides + +Scans `{base_path}/modularity/*.php` and deep-merges each file into the corresponding `modularity.{filename}` config key using `array_merge_recursive_preserve()`: + +``` +{project_root}/modularity/ + ├── navigation.php → modularity.navigation + ├── ui_settings.php → modularity.ui_settings + └── permissions.php → modularity.permissions +``` + +### 3. Navigation fallback (deprecated) + +If no `modularity/navigation.php` file exists in the app, the middleware falls back to merging `modularity-navigation` config (the legacy approach). This behaviour is deprecated since `10.0.0` — create a `modularity/navigation.php` file instead. + +## When to Use + +This middleware runs on every request in `modularity.core`. It is designed to be lightweight — only reads files that are already loaded by PHP's opcode cache. + +Application config overrides placed in `{base_path}/modularity/` are picked up without needing to republish or re-cache the full config, making it suitable for per-tenant or per-environment customisation. + +## Notes + +- Only files where `config('modularity.{filename}')` already has a value are merged. Files that introduce brand-new keys are skipped (use a service provider `mergeConfigFrom` for those). +- `array_merge_recursive_preserve` is used instead of `array_merge_recursive` to avoid duplicate array values when merging nested config arrays. diff --git a/docs/src/pages/system-reference/backend/http/middleware/log.md b/docs/src/pages/system-reference/backend/http/middleware/log.md new file mode 100644 index 000000000..5bfcd1abf --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/log.md @@ -0,0 +1,41 @@ +--- +sidebarPos: 10 +sidebarTitle: LogMiddleware +--- + +# LogMiddleware + +**File**: `src/Http/Middleware/LogMiddleware.php` +**Alias**: `modularity.log` +**Part of**: default stack (applied to ALL Modularous routes) + +Assigns a unique UUID to every request, attaches it to the `ModularityLog` context, and adds it to the HTTP response headers. + +## What It Does + +``` +Request in → generate UUID → attach to log context → [next] → set Response header → Response out +``` + +1. Generates a UUID v4 string: `$requestId = Str::uuid()`. +2. Calls `ModularityLog::withContext(['request_id' => $requestId])` — all log entries emitted during this request will include the `request_id` field. +3. After the request is handled, sets `Request-Id: {uuid}` on the response headers. + +## Response Header + +``` +Request-Id: 550e8400-e29b-41d4-a716-446655440000 +``` + +## Usage + +The `Request-Id` header allows: + +- **Log correlation** — filter all log lines for a single request by searching for the UUID. +- **Distributed tracing** — pass the header downstream to external services. +- **Debugging** — copy the header value from browser DevTools and grep the log file. + +## Notes + +- `LogMiddleware` is the first middleware in the default stack (`defaultMiddlewares`), so the UUID is available from the very first log call in any subsequent middleware. +- The context is attached via `ModularityLog::withContext()` which typically wraps a Laravel `Log::withContext()` call, making the UUID available in all Laravel log channels (file, Slack, etc.). diff --git a/docs/src/pages/system-reference/backend/http/middleware/navigation.md b/docs/src/pages/system-reference/backend/http/middleware/navigation.md new file mode 100644 index 000000000..dfe7e86fc --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/navigation.md @@ -0,0 +1,36 @@ +--- +sidebarPos: 11 +sidebarTitle: NavigationMiddleware +--- + +# NavigationMiddleware + +**File**: `src/Http/Middleware/NavigationMiddleware.php` +**Alias**: `modularity.navigation` +**Part of**: `modularity.core` group + +Shares the resolved navigation config with the Blade layout views on every request. + +## What It Does + +Registers a view composer for `modularity::layouts.*` and `translation::layout`: + +```php +view()->composer([ + 'modularity::layouts.*', + 'translation::layout', +], function ($view) { + $view->with('navigation', get_modularity_navigation_config()); +}); +``` + +`get_modularity_navigation_config()` reads the `modularity.navigation` config key (populated at request time by `LoadLocalizedConfig`) and returns the fully resolved navigation array used to render the sidebar and top navigation. + +## When It Runs + +`NavigationMiddleware` is part of the `modularity.core` group, so it runs on every Modularous route — both public web routes and authenticated panel routes. The view composer is registered lazily; it only executes when a matching layout view is actually rendered. + +## Notes + +- Navigation data is shared only with Blade views. For Inertia pages, navigation is handled inside `HandleInertiaRequests` via `storeData.config`. +- The navigation config is loaded at request time (after `LoadLocalizedConfig` has merged app overrides), so per-app navigation customisations in `{base_path}/modularity/navigation.php` are reflected without a config cache rebuild. diff --git a/docs/src/pages/system-reference/backend/http/middleware/overview.md b/docs/src/pages/system-reference/backend/http/middleware/overview.md new file mode 100644 index 000000000..a7632ce2c --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/overview.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Middleware + +**Directory**: `src/Http/Middleware/` +**Registration**: `src/Support/ModularityRoutes::generateRouteMiddlewares()` + +Modularous registers its own middleware aliases and groups during the route bootstrapping phase. All aliases use the `modularity.*` prefix to avoid conflicts with application middleware. + +## Middleware Aliases + +| Alias | Class | Description | +|-------|-------|-------------| +| `modularity.auth` | [AuthenticateMiddleware](/system-reference/backend/http/middleware/authenticate) | Guards routes; stores intended URL; handles JSON 401 | +| `modularity.guest` | [RedirectIfAuthenticatedMiddleware](/system-reference/backend/http/middleware/redirect-if-authenticated) | Redirects already-authenticated users away from guest pages | +| `modularity.log` | [LogMiddleware](/system-reference/backend/http/middleware/log) | Injects `Request-Id` UUID into every request/response | +| `modularity.utm` | [UtmMiddleware](/system-reference/backend/http/middleware/utm) | Captures UTM params and shares them with views | +| `modularity.language` | [LanguageMiddleware](/system-reference/backend/http/middleware/language) | Resolves locale, timezone, and active currency per request | +| `modularity.impersonate` | [ImpersonateMiddleware](/system-reference/backend/http/middleware/impersonate) | Activates user impersonation from session | +| `modularity.loadLocalizedConfig` | [LoadLocalizedConfig](/system-reference/backend/http/middleware/load-localized-config) | Merges deferred and app-level config files at request time | +| `modularity.navigation` | [NavigationMiddleware](/system-reference/backend/http/middleware/navigation) | Shares navigation config with Blade layout views | +| `authorization` | [AuthorizationMiddleware](/system-reference/backend/http/middleware/authorization) | Shares profile/login shortcut schemas with the master layout | +| `modularity.company.registration` | [CompanyRegistrationMiddleware](/system-reference/backend/http/middleware/company-registration) | Guards routes that require a valid company (stub) | +| `modularity.redirector` | [RedirectorMiddleware](/system-reference/backend/http/middleware/redirector) | Consumes a pending redirect URL from `RedirectService` | +| `hostable` | [HostableMiddleware](/system-reference/backend/http/middleware/hostable) | Stub for host-based routing features | +| `inertia.middleware` | [HandleInertiaRequests](/system-reference/backend/http/middleware/handle-inertia-requests) | Inertia root view + shared props (auth, flash, config, store) | +| `role` | Spatie `RoleMiddleware` | Role-based route protection (Spatie Permission) | +| `permission` | Spatie `PermissionMiddleware` | Permission-based route protection (Spatie Permission) | +| `role_or_permission` | Spatie `RoleOrPermissionMiddleware` | Role OR permission route protection (Spatie Permission) | + +## Middleware Groups + +| Group | Middleware stack | +|-------|-----------------| +| `web.auth` | `web`, `modularity.auth:{guard}` | +| `api.auth` | `api`, `throttle:api`, `modularity.auth:{guard}` | +| `modularity.core` | `modularity.utm`, `modularity.impersonate`, `modularity.language`, `modularity.loadLocalizedConfig`, `modularity.navigation`, `inertia.middleware` | +| `modularity.panel` | `authorization`, `modularity.company.registration`, `modularity.redirector` | + +## Route Stack by Type + +Every route registered by Modularous belongs to one of four stacks: + +| Route type | Middleware stack | +|------------|-----------------| +| `web` (public) | `web` + `modularity.log` + `modularity.core` | +| `webPanel` (authenticated admin) | `web.auth` + `modularity.log` + `modularity.core` + `modularity.panel` | +| `api` (public API) | `api` + `modularity.log` + `modularity.core` | +| `apiPanel` (authenticated API) | `api.auth` + `modularity.log` + `modularity.core` + `modularity.panel` | + +## Request Flow + +``` +Incoming request + └── modularity.log ← assign Request-Id UUID + └── modularity.core group + ├── modularity.utm ← capture UTM params + ├── modularity.impersonate ← swap auth user if impersonating + ├── modularity.language ← set locale / currency + ├── modularity.loadLocalizedConfig ← merge runtime config + ├── modularity.navigation ← share nav data with views + └── inertia.middleware ← share auth/flash/config with Inertia + └── [modularity.panel group — authenticated panel routes only] + ├── authorization ← share profile shortcuts with layout + ├── modularity.company.registration ← company guard + └── modularity.redirector ← consume pending redirects + └── Controller +``` diff --git a/docs/src/pages/system-reference/backend/http/middleware/redirect-if-authenticated.md b/docs/src/pages/system-reference/backend/http/middleware/redirect-if-authenticated.md new file mode 100644 index 000000000..64a723a14 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/redirect-if-authenticated.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 13 +sidebarTitle: RedirectIfAuthenticatedMiddleware +--- + +# RedirectIfAuthenticatedMiddleware + +**File**: `src/Http/Middleware/RedirectIfAuthenticatedMiddleware.php` +**Alias**: `modularity.guest` + +Protects guest-only routes (login, register, password reset) by redirecting already-authenticated users away. + +## What It Does + +```php +public function handle($request, Closure $next, $guard = 'modularity') +{ + if ($this->authFactory->guard($guard)->check()) { + return $this->redirector->to(modularityConfig('auth_login_redirect_path', '/')); + } + return $next($request); +} +``` + +If the user is already authenticated under the specified guard, they are redirected to `modularity.auth_login_redirect_path` (default: `'/'`). + +## Configuration + +```php +// config/modularity.php +'auth_login_redirect_path' => '/admin', +``` + +## Usage + +Apply to guest-only routes: + +```php +Route::middleware('modularity.guest')->group(function () { + Route::get('/login', [AuthController::class, 'showLoginForm'])->name('login.form'); + Route::post('/login', [AuthController::class, 'login'])->name('login'); +}); +``` + +## Notes + +- The guard parameter defaults to `'modularity'` but can be overridden: `modularity.guest:web`. +- This is the inverse of `modularity.auth` — one protects routes that require authentication, the other protects routes that require the user to be a guest. diff --git a/docs/src/pages/system-reference/backend/http/middleware/redirector.md b/docs/src/pages/system-reference/backend/http/middleware/redirector.md new file mode 100644 index 000000000..6e04a6f55 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/redirector.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 12 +sidebarTitle: RedirectorMiddleware +--- + +# RedirectorMiddleware + +**File**: `src/Http/Middleware/RedirectorMiddleware.php` +**Alias**: `modularity.redirector` +**Part of**: `modularity.panel` group + +Consumes a pending redirect URL stored by `RedirectService` and issues the redirect before the request reaches the controller. + +## What It Does + +```php +$redirectUrl = $this->redirectService->pull(); +if ($redirectUrl) { + return Redirect::to($redirectUrl); +} +return $next($request); +``` + +`RedirectService::pull()` reads the pending URL from the session or cache and **removes it** in the same operation. If a URL is present, the response is a redirect immediately — the controller is never invoked. + +## When It Fires + +Only on `modularity.panel` routes (authenticated admin panel). If no pending redirect is stored, the request continues normally. + +## Typical Use Case + +A controller stores a redirect URL for a future request (e.g., after a multi-step wizard or OAuth flow): + +```php +// Step 1 controller — save destination +app(RedirectService::class)->store(route('admin.orders.index')); + +// RedirectorMiddleware on the next panel request: +// → pulls the URL → redirects to admin.orders.index +// → subsequent requests pass through normally +``` + +## Related + +- [RedirectService](/system-reference/backend/services/redirect-service) — the service that stores and retrieves the pending URL. diff --git a/docs/src/pages/system-reference/backend/http/middleware/teams-permission.md b/docs/src/pages/system-reference/backend/http/middleware/teams-permission.md new file mode 100644 index 000000000..e8f2abb8b --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/teams-permission.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 14 +sidebarTitle: TeamsPermissionMiddleware +--- + +# TeamsPermissionMiddleware + +**File**: `src/Http/Middleware/TeamsPermissionMiddleware.php` + +Activates Spatie Permission's **team context** for the authenticated user by calling `setPermissionsTeamId()` at the start of each request. + +## What It Does + +```php +if (!empty(auth()->user())) { + setPermissionsTeamId(session('team_id')); +} +``` + +Reads the active team ID from `session('team_id')` (set during login) and passes it to Spatie Permission's global team context function. From this point on, all `can()` checks and `role`/`permission` middleware evaluations are scoped to that team. + +## When to Use + +Apply this middleware when your application uses Spatie Permission with `teams` enabled: + +```php +// config/permission.php +'teams' => true, +``` + +Add it to the panel middleware stack: + +```php +// In a service provider or RouteServiceProvider +Route::middlewareGroup('modularity.panel', [ + 'authorization', + 'modularity.company.registration', + 'modularity.redirector', + TeamsPermissionMiddleware::class, +]); +``` + +## Notes + +- `setPermissionsTeamId()` is provided by `spatie/laravel-permission`. If you are not using teams, this middleware has no effect. +- The `team_id` is stored in the session at login — typically from the user's primary team or a team-selection step. +- The commented-out `auth('api')` block in the source is a placeholder for API token-based team ID extraction. diff --git a/docs/src/pages/system-reference/backend/http/middleware/utm.md b/docs/src/pages/system-reference/backend/http/middleware/utm.md new file mode 100644 index 000000000..6c1734e51 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/middleware/utm.md @@ -0,0 +1,37 @@ +--- +sidebarPos: 15 +sidebarTitle: UtmMiddleware +--- + +# UtmMiddleware + +**File**: `src/Http/Middleware/UtmMiddleware.php` +**Alias**: `modularity.utm` +**Part of**: `modularity.core` group + +Captures UTM tracking parameters from the incoming request and shares them with Blade layout views. + +## What It Does + +1. Calls `Utm::getParameters()` — this reads UTM params from the request and stores them in the session (see [UtmParameters](/system-reference/backend/services/utm-parameters)). +2. Registers a view composer for both layout views: + ```php + view()->composer([ + 'modularity::layouts.app-inertia', + 'modularity::layouts.master', + ], function ($view) { + $view->with('utmParameters', Utm::getParameters()); + }); + ``` + +## Shared Variable + +| Variable | Type | Description | +|----------|------|-------------| +| `utmParameters` | `array` | Captured UTM params from session: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | + +## Notes + +- `Utm::getParameters()` is called twice — once to capture (and persist to session) and once inside the view composer to retrieve. The second call reads from the session, so it is safe even if the current request doesn't have UTM params. +- Parameters persist in the session for the configured TTL (see [UtmParameters](/system-reference/backend/services/utm-parameters)). +- For Inertia pages, `utmParameters` is available in the Blade wrapper; pass it to the Vue app via `storeData` if needed. diff --git a/docs/src/pages/system-reference/backend/http/overview.md b/docs/src/pages/system-reference/backend/http/overview.md new file mode 100644 index 000000000..c0a4e4ca4 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/overview.md @@ -0,0 +1,19 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# HTTP Layer + +**Directory**: `src/Http/` + +This section mirrors the `src/Http/` namespace, which contains the HTTP-facing pieces of Modularous: middleware, request classes, controllers (documented separately under [Controllers](/system-reference/backend/http/controllers/overview)), and view composers (documented separately under [View Composers](/system-reference/backend/http/view-composers/overview)). + +## Groups + +| Group | Summary | Page | +|-------|---------|------| +| **Controllers** | Core HTTP controller hierarchy (`CoreController` → `PanelController` → `BaseController`) plus feature controllers for auth, media, files, profile, process, and API flows | [Controllers →](/system-reference/backend/http/controllers/overview) | +| **Middleware** | 14 `modularity.*` middleware aliases and 4 groups registered during route bootstrapping | [Middleware →](/system-reference/backend/http/middleware/overview) | +| **Requests** | 7 `FormRequest` classes — two base classes (`BaseFormRequest`, `Request`) plus five concrete requests for file/media uploads, OAuth, and role/permission creation | [Requests →](/system-reference/backend/http/request/overview) | +| **View Composers** | Shared view-binding classes (`ActiveNavigation`, `CurrentUser`, uploader configs, localization, and URLs) that inject HTTP-layer data into views | [View Composers →](/system-reference/backend/http/view-composers/overview) | diff --git a/docs/src/pages/system-reference/backend/http/request/base-form-request.md b/docs/src/pages/system-reference/backend/http/request/base-form-request.md new file mode 100644 index 000000000..182a04dcb --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/base-form-request.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 2 +sidebarTitle: BaseFormRequest +--- + +# BaseFormRequest + +**File**: `src/Http/Requests/BaseFormRequest.php` +**Namespace**: `Unusualify\Modularity\Http\Requests` +**Extends**: `Illuminate\Foundation\Http\FormRequest` + +A thin convenience base for writing form requests where validation differs by HTTP method. Instead of overriding `rules()` with a branching `switch`, subclasses implement one small method per verb. + +## Method dispatch + +`rules()` inspects `$this->method()` and calls one of: + +| HTTP method | Called method | +|-------------|---------------| +| `POST` | `store()` | +| `PUT`, `PATCH` | `update()` | +| `DELETE` | `destroy()` | +| any other (`GET`, `HEAD`, …) | `view()` | + +Each of these returns an array of rules. The default implementations return empty arrays, so you only override the verbs you care about. + +```php +class StorePostRequest extends BaseFormRequest +{ + public function store(): array + { + return [ + 'title' => 'required|string|max:255', + 'body' => 'required|string', + ]; + } + + public function update(): array + { + return [ + 'title' => 'sometimes|string|max:255', + 'body' => 'sometimes|string', + ]; + } +} +``` + +## Authorization + +`authorize()` returns `true` by default. Override it if the request should be guarded. + +## Failed-validation response + +`failedValidation()` branches on `$this->wantsJson()`: + +| Client | Response | +|--------|----------| +| JSON (e.g. Inertia / API) | `400 JSON` with `{ "status": 400, "errors": <bag> }` | +| HTML | `redirect()->back()` with flash message `"Ops! Some errors occurred"` and validator errors | + +In both cases a `ValidationException` is thrown with the correct error bag and redirect URL, matching Laravel's normal control-flow expectations. + +## When to extend it + +Use `BaseFormRequest` when your rules depend on verb but not on a specific model. If you need translation-aware rules or schema merging against a model's `getTranslatedAttributes()`, extend the model-aware [`Request`](./request) class instead. + +## Known subclasses in this package + +| Subclass | Purpose | +|----------|---------| +| [`MediaRequest`](./media-request) | Media-library uploads (authorization-only; rules disabled) | diff --git a/docs/src/pages/system-reference/backend/http/request/file-request.md b/docs/src/pages/system-reference/backend/http/request/file-request.md new file mode 100644 index 000000000..f124d6833 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/file-request.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 4 +sidebarTitle: FileRequest +--- + +# FileRequest + +**File**: `src/Http/Requests/FileRequest.php` +**Namespace**: `Unusualify\Modularity\Http\Requests` +**Extends**: [`Request`](./request) + +Validates the payload posted to the file-library upload endpoint. The required fields depend on which backend is configured via `modularity.file_library.endpoint_type`. + +## Endpoint-specific rules + +`rules()` reads `modularityConfig('file_library.endpoint_type')` and returns one of three rule sets: + +| `endpoint_type` | Required fields | +|-----------------|-----------------| +| `local` | `qqfilename`, `qqfile`, `qqtotalfilesize` | +| `azure` | `blob`, `name` | +| `s3` (default) | `key`, `name` | + +The `s3` branch is used as the fallback for any unknown value. + +## Notes + +- Despite extending the model-aware [`Request`](./request) base, `FileRequest` overrides `rules()` completely — no translation expansion or `unique_table` hydration runs for this endpoint. +- The per-backend field names mirror the client libraries they integrate with: FineUploader (`qq*` fields) for `local`, the Azure Blob SDK for `azure`, and S3 presigned uploads for `s3`. + +## Related + +- [`FileLibraryController`](/system-reference/backend/http/controllers/file-library-controller) — consumes this request +- [`FilepondController`](/system-reference/backend/http/controllers/filepond-controller) — related uploader endpoint +- [Services · FileLibrary](/system-reference/backend/services/file-library/overview) diff --git a/docs/src/pages/system-reference/backend/http/request/media-request.md b/docs/src/pages/system-reference/backend/http/request/media-request.md new file mode 100644 index 000000000..7cf0f1475 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/media-request.md @@ -0,0 +1,39 @@ +--- +sidebarPos: 5 +sidebarTitle: MediaRequest +--- + +# MediaRequest + +**File**: `src/Http/Requests/MediaRequest.php` +**Namespace**: `Unusualify\Modularity\Http\Requests` +**Extends**: [`BaseFormRequest`](./base-form-request) + +The current media-library request is effectively a pass-through: it authorizes everything and performs no validation. + +## Current behaviour + +| Method | Behaviour | +|--------|-----------| +| `authorize()` | Returns `true` | +| `rules()` | Commented out — inherits the method-dispatched default from [`BaseFormRequest`](./base-form-request), which resolves to an empty array for every verb | + +## Dormant rules + +The source carries a commented-out block that mirrors the endpoint-branching pattern from [`FileRequest`](./file-request): + +```php +// switch (config('twill.media_library.endpoint_type')) { +// case 'local': return ['qqfilename' => 'required', 'qqfile' => 'required']; +// case 'azure': return ['blob' => 'required', 'name' => 'required']; +// case 's3': +// default: return ['key' => 'required', 'name' => 'required']; +// } +``` + +It is retained as a reference but is intentionally disabled. Re-enable it if you need strict validation on uploads — remember to switch the config key to `modularity.media_library.endpoint_type` rather than the legacy `twill.*` path. + +## Related + +- [`MediaLibraryController`](/system-reference/backend/http/controllers/media-library-controller) — consumes this request +- [Services · MediaLibrary](/system-reference/backend/services/media-library/overview) diff --git a/docs/src/pages/system-reference/backend/http/request/oauth-request.md b/docs/src/pages/system-reference/backend/http/request/oauth-request.md new file mode 100644 index 000000000..f66503daa --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/oauth-request.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 6 +sidebarTitle: OauthRequest +--- + +# OauthRequest + +**File**: `src/Http/Requests/OauthRequest.php` +**Namespace**: `Unusualify\Modularity\Http\Requests` +**Extends**: [`Request`](./request) + +Validates the OAuth provider portion of the authentication callback flow. The provider value is accepted from either the request body or the route parameter and must match one of the configured providers. + +## Route parameter merging + +`all()` is overridden so the `provider` route segment is surfaced as a normal request input: + +```php +public function all($keys = null) +{ + $data = parent::all(); + $data['provider'] = $this->input('provider', $this->route('provider')); + + return $data; +} +``` + +This lets downstream validation and controllers read `$request->input('provider')` regardless of whether the value came in via the URL or the posted body. + +## Rules + +```php +[ + 'provider' => ['required', Rule::in(array_keys(modularityConfig('oauth.providers', [])))], +] +``` + +The whitelist is read dynamically from `modularity.oauth.providers`, so enabling a new provider in config immediately makes it accepted here — no code change required. + +## Redirect on validation failure + +`getRedirectUrl()` attempts to return the route for the authenticated callback: + +```php +$url->route(config('modularity.admin_route_name_prefix') . '.loginHandleCallbackProvider', ['provider' => $provider]); +``` + +> [!WARNING] +> The `$provider` variable in `getRedirectUrl()` is not defined in the current source — the method will throw if validation fails and a redirect target is needed. This path is exercised only on failure, so the happy path is unaffected. + +## Related + +- [`LoginController`](/system-reference/backend/http/controllers/auth/login-controller) — consumes this request during callback handling +- `config/modularity.php` → `oauth.providers` — provider whitelist driving the `Rule::in` list diff --git a/docs/src/pages/system-reference/backend/http/request/overview.md b/docs/src/pages/system-reference/backend/http/request/overview.md new file mode 100644 index 000000000..b3f22e274 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/overview.md @@ -0,0 +1,52 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Requests + +**Directory**: `src/Http/Requests/` +**Namespace**: `Unusualify\Modularity\Http\Requests` + +Modularous ships seven `FormRequest` classes. Two of them — `BaseFormRequest` and `Request` — are base classes that the rest of the package (and consumers) extend from; the remaining five are concrete requests used by specific controllers. + +## Class hierarchy + +``` +Illuminate\Foundation\Http\FormRequest +│ +├── BaseFormRequest ← method-dispatch base (store/update/view/destroy) +│ └── MediaRequest ← media library uploads +│ +├── Request (abstract) ← model-aware base with translation + schema merging +│ ├── FileRequest ← file library uploads (varies by endpoint) +│ └── OauthRequest ← OAuth callback validation +│ +├── StorePermissionRequest ← direct FormRequest subclass +└── StoreRoleRequest ← direct FormRequest subclass +``` + +## Classes + +| Class | Base | Purpose | Page | +|-------|------|---------|------| +| `BaseFormRequest` | `FormRequest` | Dispatches validation to `view()` / `store()` / `update()` / `destroy()` based on HTTP method; custom `failedValidation()` with JSON/redirect branching | [BaseFormRequest →](./base-form-request) | +| `Request` *(abstract)* | `FormRequest` | Model-aware base that merges schema rules with translated-attribute rules across locales; injects `unique_table` / `unique_translation` helpers | [Request →](./request) | +| `FileRequest` | `Request` | Validates file-library upload payloads; rules depend on the configured `file_library.endpoint_type` (local / azure / s3) | [FileRequest →](./file-request) | +| `MediaRequest` | `BaseFormRequest` | Thin authorization pass-through for media-library requests (validation currently disabled) | [MediaRequest →](./media-request) | +| `OauthRequest` | `Request` | Validates the OAuth `provider` against `modularity.oauth.providers`; merges route param into `all()` | [OauthRequest →](./oauth-request) | +| `StorePermissionRequest` | `FormRequest` | Validates permission creation (`name` required, unique on `permissions`) | [StorePermissionRequest →](./store-permission-request) | +| `StoreRoleRequest` | `FormRequest` | Validates role creation (`name` required, unique on `roles`, min 4 chars) | [StoreRoleRequest →](./store-role-request) | + +## Extension points + +| You want to... | Extend | +|----------------|--------| +| Write a simple request with method-based rules (create/update/delete) | `BaseFormRequest` | +| Write a model-aware request that handles translations automatically | `Request` | +| Validate a permission/role-like plain payload | Laravel's `FormRequest` directly (as `StorePermissionRequest` does) | + +## Related + +- [Controllers](/system-reference/backend/http/controllers/overview) — controllers that consume these requests +- [Entity Traits · IsTranslatable](/system-reference/backend/entity-traits/translation/is-translatable) — translation flag that drives `Request::mergeRules()` diff --git a/docs/src/pages/system-reference/backend/http/request/request.md b/docs/src/pages/system-reference/backend/http/request/request.md new file mode 100644 index 000000000..ce2210465 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/request.md @@ -0,0 +1,100 @@ +--- +sidebarPos: 3 +sidebarTitle: Request +--- + +# Request (abstract) + +**File**: `src/Http/Requests/Request.php` +**Namespace**: `Unusualify\Modularity\Http\Requests` +**Extends**: `Illuminate\Foundation\Http\FormRequest` +**Uses**: `Unusualify\Modularity\Traits\ManageTraits` + +An abstract model-aware form request that knows how to merge validation rules across translated attributes and inject model-scoped helpers. Intended as the base class for any request that validates payloads destined for a Modularous Eloquent model. + +## Construction + +```php +public function __construct(protected $rules = []) +{ + $this->model = $this->model(); +} +``` + +- `$rules` — optional default schema passed at construction (used by `mergeSchemaRules()`). +- `model()` comes from `ManageTraits` and resolves the model class the request is for. + +## Rule dispatch + +Unlike [`BaseFormRequest`](./base-form-request), this class branches only on `POST` / `PUT`: + +| HTTP method | Rules pipeline | +|-------------|----------------| +| `POST` | `mergeRules( rulesForAll() + rulesForCreate() )` | +| `PUT` | `mergeRules( rulesForAll() + rulesForUpdate() )` | +| other | `[]` | + +Subclasses implement `rulesForAll()`, `rulesForCreate()`, and `rulesForUpdate()` to describe their schema. + +## Translation-aware rule merging + +`mergeRules()` performs three steps: + +1. **`mergeSchemaRules($rules)`** — placeholder hook for combining constructor-provided `$this->rules` with method rules. Currently passes through unchanged. +2. **Split translated vs. non-translated attributes** using the model's `getTranslatedAttributes()` (only if the model uses the [`IsTranslatable`](/system-reference/backend/entity-traits/translation/is-translatable) trait). +3. **Expand translated rules per locale** by calling `updateRules()` once for each locale in `getLocales()`, producing keys like `title.en`, `title.tr`, etc. + +The result is then run through [`hydrateRules()`](#rule-hydration) to expand helper tokens. + +## Per-locale rule expansion + +For every translated field, `updateRules()`: + +- Creates per-locale keys (`field.en`, `field.tr`, …). +- Drops `required*` rules and replaces them with `nullable` when the locale is not active (`$localeActive === false`). +- Rewrites `required_*` rules that reference peer fields so they point to the locale-specific key (`required_with:name` → `required_with:name.en`). +- Translates `unique_translation` rules into a closure that queries the translation model, excluding the current record by its `translationRelationKey` if `id` is present. + +## Rule hydration + +`hydrateRules()` scans string rules for the token `unique_table` and rewrites it as: + +```text +unique:<model-table>,<field>[,<id>] +``` + +The `id` suffix is appended automatically when `$this->id` is set, making the rule safe to use for both store and update flows. + +Example — a subclass can write: + +```php +public function rulesForAll(): array +{ + return [ + 'email' => 'required|email|unique_table', + ]; +} +``` + +and on update it expands to `unique:users,email,42`. + +## Translated message helper + +`messagesForTranslatedFields($messages, $fields)` and its private helper `updateMessages()` mirror the rule expansion for error messages — they fan each defined message out across every locale, replacing `{lang}` with the actual locale code. + +## Authorization + +`authorize()` returns `true`. Override in a subclass if the action should be guarded. + +## Known subclasses in this package + +| Subclass | Purpose | +|----------|---------| +| [`FileRequest`](./file-request) | File-library upload payloads keyed by endpoint type | +| [`OauthRequest`](./oauth-request) | OAuth provider validation on callback | + +## Related + +- [`ManageTraits`](#) — resolves `$this->model()` (internal plumbing trait) +- [`IsTranslatable`](/system-reference/backend/entity-traits/translation/is-translatable) — the flag that activates per-locale rule expansion +- [`HasTranslation`](/system-reference/backend/entity-traits/translation/has-translation) — provides `getTranslationModelName()` and `getTranslationRelationKey()` used by the closure-based `unique_translation` rule diff --git a/docs/src/pages/system-reference/backend/http/request/store-permission-request.md b/docs/src/pages/system-reference/backend/http/request/store-permission-request.md new file mode 100644 index 000000000..2d09b6bfa --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/store-permission-request.md @@ -0,0 +1,32 @@ +--- +sidebarPos: 7 +sidebarTitle: StorePermissionRequest +--- + +# StorePermissionRequest + +**File**: `src/Http/Requests/StorePermissionRequest.php` +**Namespace**: `Unusualify\Modularity\Http\Requests` +**Extends**: `Illuminate\Foundation\Http\FormRequest` + +Validates the payload for creating a new Spatie permission. + +## Rules + +| Field | Rule | +|-------|------| +| `name` | `required`, unique on the `permissions` table | + +## Authorization + +`authorize()` returns `true` — any authenticated user allowed to reach the controller may submit this request. Gate the controller action itself (with `permission:permissions.create` or similar) if you need stricter access control. + +## Notes + +- Extends `FormRequest` directly rather than one of Modularous's base classes, because the payload is a flat plain-text field with no model-aware translation logic required. +- The unique check is hard-coded against the `permissions` table name. If you publish and rename the Spatie tables, update this class accordingly. + +## Related + +- Spatie Permission package — provides the underlying `permissions` table and `Permission` model +- [`PermissionResource`](#) — the API resource used to render these records diff --git a/docs/src/pages/system-reference/backend/http/request/store-role-request.md b/docs/src/pages/system-reference/backend/http/request/store-role-request.md new file mode 100644 index 000000000..29c22ad7d --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/request/store-role-request.md @@ -0,0 +1,32 @@ +--- +sidebarPos: 8 +sidebarTitle: StoreRoleRequest +--- + +# StoreRoleRequest + +**File**: `src/Http/Requests/StoreRoleRequest.php` +**Namespace**: `Unusualify\Modularity\Http\Requests` +**Extends**: `Illuminate\Foundation\Http\FormRequest` + +Validates the payload for creating a new Spatie role. + +## Rules + +| Field | Rule | +|-------|------| +| `name` | `required`, unique on the `roles` table, minimum 4 characters | + +## Authorization + +`authorize()` returns `true` — access control is expected to be enforced at the controller / middleware level rather than inside this request. + +## Notes + +- Extends `FormRequest` directly; no model-aware translation logic is needed for role names. +- The 4-character minimum is a convention to discourage ambiguous role names (e.g. `qa`, `it`). Adjust via override if your application requires shorter role identifiers. + +## Related + +- Spatie Permission package — provides the underlying `roles` table and `Role` model +- [`RoleResource`](#) — the API resource used to render these records diff --git a/docs/src/pages/system-reference/backend/http/view-composers/active-navigation.md b/docs/src/pages/system-reference/backend/http/view-composers/active-navigation.md new file mode 100644 index 000000000..a412bd7b6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/view-composers/active-navigation.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 2 +sidebarTitle: ActiveNavigation +--- + +# ActiveNavigation + +**Class**: `Unusualify\Modularity\Http\ViewComposers\ActiveNavigation` +**Source**: `src/Http/ViewComposers/ActiveNavigation.php` + +Parses the current route name into up to three navigation depth markers and injects them into every view. The frontend uses these variables to highlight the correct sidebar menu items. + +## Injected Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `_global_active_navigation` | `string` | Second segment of the route name (index `[1]`) — the top-level module or section (e.g. `posts`) | +| `_primary_active_navigation` | `string\|null` | Third segment (index `[2]`) — the sub-section or action (e.g. `index`). Falls back to the first route parameter when the route has fewer than three segments. | +| `_secondary_active_navigation` | `string\|null` | Fourth segment (index `[3]`) — a nested sub-section. Collapses back to the primary segment when the fourth segment is `index`. | + +## Route Name Convention + +All admin routes follow the pattern `admin.{module}.{action}.{sub}`. The composer splits on `.` and reads each depth: + +``` +Route name: admin.posts.index + _global_active_navigation = 'posts' + _primary_active_navigation = 'index' + _secondary_active_navigation = (not set) + +Route name: admin.posts.categories.edit + _global_active_navigation = 'posts' + _primary_active_navigation = 'categories' + _secondary_active_navigation = 'edit' + +Route name: admin.posts.categories.index + _global_active_navigation = 'posts' + _primary_active_navigation = 'categories' + _secondary_active_navigation = 'categories' ← collapses 'index' to parent +``` + +## Fallback for Route Parameters + +When the route has only two segments but has route parameters (e.g. `admin.posts` with `{id}`), `_primary_active_navigation` is set to the value of the first route parameter. This handles resource show/edit routes that omit the action segment. + +## Notes + +- Does nothing when `$request->route()` returns `null` (console, queue, or test context without a bound route). +- The composer uses `Arr::only()` to merge with any values already present on the view, so manually passed navigation overrides take precedence. diff --git a/docs/src/pages/system-reference/backend/http/view-composers/current-user.md b/docs/src/pages/system-reference/backend/http/view-composers/current-user.md new file mode 100644 index 000000000..0c35a26aa --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/view-composers/current-user.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 3 +sidebarTitle: CurrentUser +--- + +# CurrentUser + +**Class**: `Unusualify\Modularity\Http\ViewComposers\CurrentUser` +**Source**: `src/Http/ViewComposers/CurrentUser.php` + +Injects the authenticated user into every view as `$currentUser`. Uses Modularous configured auth guard rather than the default Laravel guard, ensuring the correct user is resolved in multi-guard setups. + +## Injected Variable + +| Variable | Type | Description | +|----------|------|-------------| +| `currentUser` | `array\|null` | The authenticated user's profile data, or `null` when no user is authenticated | + +## Behaviour + +1. Resolves the auth guard name from `Modularity::getAuthGuardName()`. +2. Retrieves the authenticated user via `AuthFactory::guard($guardName)->user()`. +3. If a user is found, passes it through the `get_user_profile($user)` helper, which normalises the model into a plain array with the fields expected by the frontend (name, email, avatar, role, etc.). +4. Exposes the result as `compact('currentUser')` — `null` when unauthenticated. + +## Usage in Views + +```blade +{{-- Blade --}} +@if($currentUser) + Welcome, {{ $currentUser['name'] }} +@endif +``` + +```js +// Inertia (shared props) +const { currentUser } = usePage().props +``` + +## Configuration + +The guard name is read from the Modularous config: + +```php +// config/modularity.php +'auth' => [ + 'guard' => 'web', // used by Modularity::getAuthGuardName() +], +``` diff --git a/docs/src/pages/system-reference/backend/http/view-composers/files-uploader-config.md b/docs/src/pages/system-reference/backend/http/view-composers/files-uploader-config.md new file mode 100644 index 000000000..5c4ef59d2 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/view-composers/files-uploader-config.md @@ -0,0 +1,55 @@ +--- +sidebarPos: 4 +sidebarTitle: FilesUploaderConfig +--- + +# FilesUploaderConfig + +**Class**: `Unusualify\Modularity\Http\ViewComposers\FilesUploaderConfig` +**Source**: `src/Http/ViewComposers/FilesUploaderConfig.php` + +Builds and injects the file-library upload configuration object into every view. The frontend file uploader (Filepond or equivalent) reads `$filesUploaderConfig` to know where and how to upload files. + +## Injected Variable + +| Variable | Type | Description | +|----------|------|-------------| +| `filesUploaderConfig` | `array` | Complete upload configuration for the file library | + +## Config Object Shape + +| Key | Source | Description | +|-----|--------|-------------| +| `endpointType` | `file_library.endpoint_type` | Storage backend: `local`, `s3`, or `azure` | +| `endpoint` | Resolved by `endpointType` | The URL the uploader POSTs files to | +| `successEndpoint` | `file-library.file.store` route | Always the local store route — used for the post-upload success callback | +| `signatureEndpoint` | `file-library.sign-s3-upload` or `sign-azure-upload` route | Pre-signed URL endpoint; `null` for `local` | +| `endpointBucket` | `filesystems.disks.{disk}.bucket` | S3/Azure bucket name; `'none'` when absent | +| `endpointRegion` | `filesystems.disks.{disk}.region` | S3 region; `'none'` when absent | +| `endpointRoot` | `filesystems.disks.{disk}.root` | Root path prefix; empty string for `local` | +| `accessKey` | `filesystems.disks.{disk}.key` | Storage access key; `'none'` when absent | +| `csrfToken` | Session token | Laravel CSRF token for request validation | +| `acl` | `file_library.acl` | S3/Azure ACL policy (e.g. `public-read`) | +| `filesizeLimit` | `file_library.filesize_limit` | Maximum upload size in bytes | +| `allowedExtensions` | `file_library.allowed_extensions` | Array of permitted file extensions | + +## Endpoint Resolution by Type + +| `endpointType` | `endpoint` | `signatureEndpoint` | +|----------------|-----------|---------------------| +| `local` | `file-library.file.store` route URL | `null` | +| `s3` | `s3Endpoint($libraryDisk)` helper | `file-library.sign-s3-upload` route URL | +| `azure` | `azureEndpoint($libraryDisk)` helper | `file-library.sign-azure-upload` route URL | + +## Configuration + +```php +// config/modularity.php +'file_library' => [ + 'disk' => 'local', + 'endpoint_type' => 'local', // 'local' | 's3' | 'azure' + 'allowed_extensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx'], + 'acl' => 'private', + 'filesize_limit' => 10485760, // 10 MB in bytes +], +``` diff --git a/docs/src/pages/system-reference/backend/http/view-composers/localization.md b/docs/src/pages/system-reference/backend/http/view-composers/localization.md new file mode 100644 index 000000000..026539565 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/view-composers/localization.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 5 +sidebarTitle: Localization +--- + +# Localization + +**Class**: `Unusualify\Modularity\Http\ViewComposers\Localization` +**Source**: `src/Http/ViewComposers/Localization.php` + +Injects the full Modularous locale and language configuration into every view as `$modularityLocalization`. The frontend i18n layer reads this object to initialise locale settings, available languages, and translation strings without a separate API call. + +## Injected Variable + +| Variable | Type | Description | +|----------|------|-------------| +| `modularityLocalization` | `array` | Full locale configuration returned by `get_modularity_localization_config()` | + +## Data Shape + +The shape of `$modularityLocalization` is determined by the `get_modularity_localization_config()` helper, which typically returns: + +| Key | Description | +|-----|-------------| +| `locale` | Current application locale (e.g. `en`) | +| `fallback_locale` | Fallback locale when a translation is missing | +| `locales` | All enabled locales as `[{value, label}]` pairs | +| `translations` | Flat key→value translation strings for the frontend | + +## Usage in Views + +```blade +{{-- Available as a Blade variable --}} +@json($modularityLocalization) +``` + +```js +// Inertia (shared props) +const { modularityLocalization } = usePage().props + +const { locale, locales, translations } = modularityLocalization +``` + +## Configuration + +Locale settings are controlled via the Modularous config and Laravel's `app.locale`: + +```php +// config/modularity.php +'locales' => [ + ['value' => 'en', 'label' => 'English'], + ['value' => 'tr', 'label' => 'Türkçe'], +], +``` diff --git a/docs/src/pages/system-reference/backend/http/view-composers/medias-uploader-config.md b/docs/src/pages/system-reference/backend/http/view-composers/medias-uploader-config.md new file mode 100644 index 000000000..183cdc653 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/view-composers/medias-uploader-config.md @@ -0,0 +1,59 @@ +--- +sidebarPos: 6 +sidebarTitle: MediasUploaderConfig +--- + +# MediasUploaderConfig + +**Class**: `Unusualify\Modularity\Http\ViewComposers\MediasUploaderConfig` +**Source**: `src/Http/ViewComposers/MediasUploaderConfig.php` + +Builds and injects the media-library upload configuration object into every view. The frontend image/media uploader reads `$mediasUploaderConfig` to know where and how to upload media files. Mirrors [`FilesUploaderConfig`](./files-uploader-config) but targets the media library instead of the file library. + +## Injected Variable + +| Variable | Type | Description | +|----------|------|-------------| +| `mediasUploaderConfig` | `array` | Complete upload configuration for the media library | + +## Config Object Shape + +| Key | Source | Description | +|-----|--------|-------------| +| `endpointType` | `media_library.endpoint_type` | Storage backend: `local`, `s3`, or `azure` | +| `endpoint` | Resolved by `endpointType` | The URL the uploader POSTs media files to | +| `successEndpoint` | `media-library.media.store` route | Always the local store route — used for the post-upload success callback | +| `signatureEndpoint` | `media-library.sign-s3-upload` or `sign-azure-upload` route | Pre-signed URL endpoint; `null` for `local` | +| `endpointBucket` | `filesystems.disks.{disk}.bucket` | S3/Azure bucket name; `'none'` when absent | +| `endpointRegion` | `filesystems.disks.{disk}.region` | S3 region; `'none'` when absent | +| `endpointRoot` | `filesystems.disks.{disk}.root` | Root path prefix; empty string for `local` | +| `accessKey` | `filesystems.disks.{disk}.key` | Storage access key; `'none'` when absent | +| `csrfToken` | Session token | Laravel CSRF token for request validation | +| `acl` | `media_library.acl` | S3/Azure ACL policy (e.g. `public-read`) | +| `filesizeLimit` | `media_library.filesize_limit` | Maximum upload size in bytes | +| `allowedExtensions` | `media_library.allowed_extensions` | Array of permitted media extensions | + +## Endpoint Resolution by Type + +| `endpointType` | `endpoint` | `signatureEndpoint` | +|----------------|-----------|---------------------| +| `local` | `media-library.media.store` route URL | `null` | +| `s3` | `s3Endpoint($libraryDisk)` helper | `media-library.sign-s3-upload` route URL | +| `azure` | `azureEndpoint($libraryDisk)` helper | `media-library.sign-azure-upload` route URL | + +## Difference from FilesUploaderConfig + +`MediasUploaderConfig` reads from `media_library.*` config keys and targets the `media-library.*` admin routes. `FilesUploaderConfig` reads from `file_library.*` and targets `file-library.*` routes. Both produce the same shape of configuration object. + +## Configuration + +```php +// config/modularity.php +'media_library' => [ + 'disk' => 's3', + 'endpoint_type' => 's3', // 'local' | 's3' | 'azure' + 'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], + 'acl' => 'public-read', + 'filesize_limit' => 52428800, // 50 MB in bytes +], +``` diff --git a/docs/src/pages/system-reference/backend/http/view-composers/overview.md b/docs/src/pages/system-reference/backend/http/view-composers/overview.md new file mode 100644 index 000000000..69546766a --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/view-composers/overview.md @@ -0,0 +1,26 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: View Composers +--- + +# View Composers + +**Directory**: `src/Http/ViewComposers/` + +View composers are classes that bind data to every view (or Inertia response) before it is rendered. Modularous registers them automatically in `BaseServiceProvider`. They inject cross-cutting data — the authenticated user, active navigation state, upload configuration, locale settings, and common URLs — so controllers never have to pass this data manually. + +## Composers + +| Composer | Variable injected | Description | +|----------|-------------------|-------------| +| [ActiveNavigation](./active-navigation) | `_global_active_navigation`, `_primary_active_navigation`, `_secondary_active_navigation` | Parses the current route name into navigation depth markers | +| [CurrentUser](./current-user) | `currentUser` | Injects the authenticated user profile using Modularous auth guard | +| [FilesUploaderConfig](./files-uploader-config) | `filesUploaderConfig` | Builds the file-library upload configuration object (endpoint, CSRF, ACL, limits) | +| [Localization](./localization) | `modularityLocalization` | Injects the full locale/language configuration for frontend i18n | +| [MediasUploaderConfig](./medias-uploader-config) | `mediasUploaderConfig` | Builds the media-library upload configuration object (endpoint, CSRF, ACL, limits) | +| [Urls](./urls) | `urls` | Injects commonly used admin route URLs (profile show/update) | + +## Registration + +All composers are bound in `BaseServiceProvider::registerViewComposers()`. They run on every request that renders a view, so the injected variables are always available in Blade templates and Inertia shared props without any controller boilerplate. diff --git a/docs/src/pages/system-reference/backend/http/view-composers/urls.md b/docs/src/pages/system-reference/backend/http/view-composers/urls.md new file mode 100644 index 000000000..c481cc643 --- /dev/null +++ b/docs/src/pages/system-reference/backend/http/view-composers/urls.md @@ -0,0 +1,52 @@ +--- +sidebarPos: 7 +sidebarTitle: Urls +--- + +# Urls + +**Class**: `Unusualify\Modularity\Http\ViewComposers\Urls` +**Source**: `src/Http/ViewComposers/Urls.php` + +Injects a `$urls` array of commonly used admin route URLs into every view. Centralises route URL generation so frontend components can reference named routes without hardcoding paths or calling `route()` in multiple places. + +## Injected Variable + +| Variable | Type | Description | +|----------|------|-------------| +| `urls` | `array` | Map of named URL keys to their resolved admin route URLs | + +## URL Map + +| Key | Route | Description | +|-----|-------|-------------| +| `profileShow` | `admin.profile.show` | URL to the current user's profile view page | +| `profileUpdate` | `admin.profile.update` | URL to the profile update endpoint (PUT/PATCH) | + +## Usage in Views + +```blade +{{-- Blade --}} +<a href="{{ $urls['profileShow'] }}">My Profile</a> +``` + +```js +// Inertia (shared props) +const { urls } = usePage().props + +// Use directly in a link or form action +router.put(urls.profileUpdate, formData) +``` + +## Adding More URLs + +The `Urls` composer is intentionally minimal. To add more globally available URLs, extend the composer or create a new one and register it in your application's service provider: + +```php +// In AppServiceProvider::boot() +view()->composer('*', function ($view) { + $view->with('urls', array_merge($view->getData()['urls'] ?? [], [ + 'dashboard' => route('admin.dashboard'), + ])); +}); +``` diff --git a/docs/src/pages/system-reference/backend/notifications/auth-notifications.md b/docs/src/pages/system-reference/backend/notifications/auth-notifications.md new file mode 100644 index 000000000..6e6656f52 --- /dev/null +++ b/docs/src/pages/system-reference/backend/notifications/auth-notifications.md @@ -0,0 +1,137 @@ +--- +sidebarPos: 1 +sidebarTitle: Auth Notifications +--- + +# Auth Notifications + +`Unusualify\Modularity\Notifications\` + +Three transactional email notifications that cover the user registration and password management flows. Each class extends Laravel's `Notification` directly and delivers via the `mail` channel only. + +--- + +## EmailVerification + +`Unusualify\Modularity\Notifications\EmailVerification` + +Sent when a new user needs to verify their email address before completing registration. + +### Constructor + +```php +public function __construct(string $token, array $parameters = []) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$token` | `string` | Verification token embedded in the action URL | +| `$parameters` | `array` | Extra route parameters merged into the verification URL | + +### Mail content + +| Field | Value | +|-------|-------| +| Subject | `"Email Verification"` | +| Action label | `"Verify Email Address"` | +| Action URL | `route('complete.register.form', [...$parameters, 'token' => $token, 'email' => $notifiable->email])` | +| Expiry line | Uses `config('auth.passwords.users.expire', 60)` minutes | + +### Usage + +```php +$user->notify(new EmailVerification($token, ['invite' => $inviteId])); +``` + +### Notes + +- The verification URL is built via `Route::hasAdmin('complete.register.form')`, so the route resolves through the admin route group. +- `$parameters` lets callers embed extra context (e.g. an invitation ID) into the URL for retrieval after verification. + +--- + +## GeneratePasswordNotification + +`Unusualify\Modularity\Notifications\GeneratePasswordNotification` + +Sent to new users who were created without a password (e.g. admin-invited accounts). The email contains a link where the user sets their password for the first time. + +### Constructor + +```php +public function __construct(string $token) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$token` | `string` | Password-generation token | + +### Mail content + +| Field | Value | +|-------|-------| +| Subject | `"Generate Your Password For New Account"` | +| Action label | `"Generate Password"` | +| Action URL | `route('admin.register.password.generate.form', ['token' => $token, 'email' => $notifiable->getEmailForPasswordGeneration()])` | + +### Static hooks + +Two static callbacks let you replace the URL or the full mail message without subclassing: + +```php +// Override the action URL +GeneratePasswordNotification::createUrlUsing(function ($notifiable, $token) { + return route('my.custom.generate', ['token' => $token]); +}); + +// Override the entire MailMessage +GeneratePasswordNotification::toMailUsing(function ($notifiable, $token) { + return (new MailMessage)->subject('Set Up Your Account')->action('Get Started', '...'); +}); +``` + +| Method | Callback signature | Effect | +|--------|--------------------|--------| +| `createUrlUsing(callable $callback)` | `fn($notifiable, $token): string` | Replaces `generatePasswordUrl()` | +| `toMailUsing(callable $callback)` | `fn($notifiable, $token): MailMessage` | Replaces the entire `toMail()` output | + +### Notes + +- The notifiable must implement `getEmailForPasswordGeneration()` — typically the user's email attribute. +- The class is **not** queued. Wrap in a queued job if needed. + +--- + +## ResetPasswordNotification + +`Unusualify\Modularity\Notifications\ResetPasswordNotification` + +Overrides Laravel's built-in `Illuminate\Auth\Notifications\ResetPassword` with an app-branded mail template. The password-reset URL and token logic are inherited from the parent class. + +### Mail content + +| Field | Value | +|-------|-------| +| Subject | `"Reset your {app.name} password"` | +| Greeting | `"Hi {user.name},"` | +| Body | Explains the reset request and links to the reset form | +| Action label | `"Reset Password"` | +| Expiry line | Uses `config('auth.passwords.{defaults.passwords}.expire')` | +| Salutation | `"Regards, {app.name} Support"` | + +### Static hook + +The parent class exposes a `toMailUsing` callback; this class honours it: + +```php +ResetPasswordNotification::toMailUsing(function ($notifiable, $token) { + return (new MailMessage) + ->subject('Custom Reset Subject') + ->action('Reset Now', url('/reset/' . $token)); +}); +``` + +### Notes + +- No constructor override — the token is passed by the parent's `ResetPassword` contract. +- All text is wrapped in `Lang::get()` so translation files in `resources/lang/` can override the copy. diff --git a/docs/src/pages/system-reference/backend/notifications/feature-notification.md b/docs/src/pages/system-reference/backend/notifications/feature-notification.md new file mode 100644 index 000000000..fc07bc876 --- /dev/null +++ b/docs/src/pages/system-reference/backend/notifications/feature-notification.md @@ -0,0 +1,230 @@ +--- +sidebarPos: 2 +sidebarTitle: FeatureNotification +--- + +# FeatureNotification + +`Modules\SystemNotification\Notifications\FeatureNotification` + +Abstract base class for all system notifications in the `SystemNotification` module. Extends Laravel's `Notification` and adds: + +- **Multi-channel dispatch** with per-class config overrides +- **Callback hooks** to customise every part of the mail/database payload without subclassing +- **Database payload** with a consistent schema for the in-app notification centre +- **Queue routing** per channel (mail queue vs. database queue) +- **Failure logging** to a dedicated `modularity-notification-failure` log channel + +All concrete system notifications (`ModelCreatedNotification`, `StateableUpdatedNotification`, etc.) extend this class. + +--- + +## Constructor + +```php +public function __construct(public Model $model) +``` + +Sets `$this->model` and generates a unique `$token` via `uniqid()`. The token is stored in the database payload and used to locate the notification record when building mail redirector URLs. + +--- + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$model` | `Model` | — | The Eloquent model the notification is about | +| `$token` | `string` | `uniqid()` | Unique token per notification instance | +| `$modelHeadline` | `string\|null` | `null` | Override the human-readable model name | +| `$modelTitleField` | `string\|null` | `null` | Override the model title field resolver | +| `$salutationMessage` | `string\|null` | `null` | Override the mail salutation text | +| `$validChannels` | `array` | `['mail','database','broadcast','vonage','slack']` | Channels that are accepted for dispatch | +| `$defaultChannels` | `array` | `[]` | Default channels when no config override is present | +| `$outroLines` | `array` | `[]` | Extra lines appended at the bottom of the mail body | + +--- + +## Channel Resolution + +`via()` resolves channels in this order: + +1. Check `config("modularity.notifications.{FullyQualifiedClassName}.channels")` — comma-separated string. +2. Fall back to `$defaultChannels` on the subclass. + +Invalid channel names (not in `$validChannels`) are filtered out automatically. + +```php +// Override channels for a specific notification via config: +// config/modularity.php +'notifications' => [ + \Modules\SystemNotification\Notifications\StateableUpdatedNotification::class => [ + 'channels' => 'mail,database', + ], +], +``` + +--- + +## Queue Routing + +```php +public function viaConnections(): array +// mail → modularityConfig('notifications.mail_connection') +// database → modularityConfig('notifications.database_connection') + +public function viaQueues(): array +// mail → modularityConfig('notifications.mail_queue') +// database → modularityConfig('notifications.database_queue') +``` + +--- + +## Callback System + +Every part of the notification output can be replaced via static callbacks registered on the specific subclass **or** on `FeatureNotification` itself (acts as a global override). + +### Available Callbacks + +| Static method | Callback signature | What it replaces | +|---------------|--------------------|-----------------| +| `createModuleRouteHeadline(callable)` | `fn(Model): string` | Human-readable model name in subject/body | +| `createModelTitleField(callable)` | `fn(Model): string` | The model's display title (name/title/slug/id) | +| `createSubject(callable)` | `fn(notifiable, Model, string $default): string` | In-app notification subject | +| `createMailSubject(callable)` | `fn(notifiable, Model, string $default): string` | Mail `subject:` line | +| `createMessage(callable)` | `fn(notifiable, Model, string $default): string` | Plain-text notification body | +| `createHtmlMessage(callable)` | `fn(notifiable, Model, string $default): string` | HTML notification body | +| `createMailMessage(callable)` | `fn(notifiable, Model, string $default): string` | Mail body line | +| `createActionText(callable)` | `fn(notifiable, Model, string $default): string` | Button label in the notification centre | +| `createMailActionText(callable)` | `fn(notifiable, Model, string $default): string` | Button label in the mail | +| `createMailSalutation(callable)` | `fn(string $default): string` | Mail salutation | +| `createDatabaseFeatureFields(callable)` | `fn(array $fields, notifiable, Model): array` | Merge extra fields into `toDatabase()` payload | +| `updateLaravelMailMessage(callable)` | `fn(MailMessage, notifiable, Model): MailMessage` | Post-process the entire `MailMessage` object | + +### Example: Override the mail subject for one notification type + +```php +use Modules\SystemNotification\Notifications\StateableUpdatedNotification; + +StateableUpdatedNotification::createMailSubject( + fn($notifiable, $model, $default) => "Status update for {$model->name}" +); +``` + +### Example: Add an extra line to every FeatureNotification mail + +```php +use Modules\SystemNotification\Notifications\FeatureNotification; + +FeatureNotification::createMailSalutation(fn($default) => 'Thanks, The Team'); +``` + +--- + +## Database Payload + +`toDatabase()` returns the array stored in `notifications.data`. The base structure is built by `toDatabaseFeatureFields()`: + +```php +[ + 'token' => string, // uniqid, used for mail redirector lookup + 'subject' => string, // notification centre subject + 'message' => string, // plain text body + 'htmlMessage' => string, // HTML body + 'redirectorText' => string, // action button label + 'redirector' => string|null, // URL to the relevant admin panel page + 'hasRedirector' => bool, +] +``` + +Use `createDatabaseFeatureFields` to merge additional keys: + +```php +StateableUpdatedNotification::createDatabaseFeatureFields( + fn($fields, $notifiable, $model) => ['old_state' => $model->previousState] +); +``` + +--- + +## Redirector Resolution + +`getNotificationRedirector()` builds an admin panel URL pointing to the model's edit or index page. It uses `Modularity::find($moduleName)->getRouteActionUrl(...)` and respects the module's `editOnModal` config: + +- `editOnModal = true` → links to the index page with the row ID (opens modal) +- `editOnModal = false` → links directly to the `edit` page + +`getNotificationMailRedirector()` instead links to the in-app notification detail route (`admin.system.system_notification.my_notification.show`) so the mail button records a view. + +--- + +## Model Title Resolution + +`getModelTitleField()` resolves the model's display title in this order: + +1. `createModelTitleField` callback (subclass-specific, then base-class) +2. `$model->notificationTitleField` property +3. `$model->getTitleValue()` method +4. `$model->name` → `$model->title` → `$model->slug` → `$model->id` + +Override with a property on your model: + +```php +// In your Eloquent model: +public string $notificationTitleField = 'display_name'; +``` + +--- + +## Outro Lines + +Add extra lines at the bottom of the mail body without subclassing: + +```php +$notification = new ModelCreatedNotification($model); +$notification->addOutroLine('This is an automated message.'); +$user->notify($notification); +``` + +Or set them as a default on the subclass: + +```php +class MyNotification extends FeatureNotification +{ + public array $outroLines = ['Do not reply to this email.']; +} +``` + +--- + +## AfterSendable Contract + +`Modules\SystemNotification\Notifications\Contracts\AfterSendable` + +Notifications that implement this interface receive an `afterNotificationSent($notifiable)` callback after the notification is dispatched. Use it for post-send side effects (e.g. logging, updating a flag). + +```php +interface AfterSendable +{ + public function afterNotificationSent($notifiable): void; +} +``` + +`ChatableUnreadNotification` is the only built-in class that implements `AfterSendable`. + +--- + +## Failure Handling + +If a queued notification fails, `failed()` logs the exception to the `modularity-notification-failure` log channel: + +```php +public function failed(?\Throwable $exception): void +{ + Log::channel('modularity-notification-failure')->error( + static::class . ' failed: ' . $exception->getMessage(), + ['exception' => $exception] + ); +} +``` + +Configure this channel in `config/logging.php` to route notification failures to a separate file or alerting service. diff --git a/docs/src/pages/system-reference/backend/notifications/overview.md b/docs/src/pages/system-reference/backend/notifications/overview.md new file mode 100644 index 000000000..59c0cd48f --- /dev/null +++ b/docs/src/pages/system-reference/backend/notifications/overview.md @@ -0,0 +1,111 @@ +--- +sidebarPos: 15 +sidebarTitle: Overview +sidebarGroupTitle: Notifications +--- + +# Notifications + +Modularous ships two tiers of notifications that serve different purposes. + +| Tier | Namespace | Purpose | +|------|-----------|---------| +| **Auth notifications** | `Unusualify\Modularity\Notifications\` | Low-level transactional emails for registration and password flows | +| **System notifications** | `Modules\SystemNotification\Notifications\` | Feature-rich, queue-able, multi-channel notifications tied to model lifecycle events | + +--- + +## Tier 1 — Auth Notifications + +Three standalone notification classes in `src/Notifications/`: + +| Class | Triggered by | Email subject | +|-------|-------------|---------------| +| [EmailVerification](./auth-notifications#emailverification) | Registration with email verification | "Email Verification" | +| [GeneratePasswordNotification](./auth-notifications#generatepasswordnotification) | New account with generated password | "Generate Your Password For New Account" | +| [ResetPasswordNotification](./auth-notifications#resetpasswordnotification) | Forgot-password flow | "Reset your {app} password" | + +These extend Laravel's `Notification` directly, deliver only via `mail`, and are **not queued** by default. + +→ [Auth Notifications reference](./auth-notifications) + +--- + +## Tier 2 — System Notifications + +Fourteen notification classes in the `SystemNotification` module, all extending `FeatureNotification`: + +| Class | Triggered by | Default channels | +|-------|-------------|-----------------| +| [ModelCreatedNotification](./system-notifications#model-lifecycle) | `ModelCreated` event | `mail` | +| [ModelUpdatedNotification](./system-notifications#model-lifecycle) | `ModelUpdated` event | `mail` | +| [ModelDeletedNotification](./system-notifications#model-lifecycle) | `ModelDeleted` event | `mail` | +| [ModelRestoredNotification](./system-notifications#model-lifecycle) | `ModelRestored` event | `mail` | +| [ModelForceDeletedNotification](./system-notifications#model-lifecycle) | `ModelForceDeleted` event | `mail` | +| [StateableUpdatedNotification](./system-notifications#stateable) | `StateableUpdated` event | configurable | +| [TaskCreatedNotification](./system-notifications#tasks) | `AssignmentCreated` event | configurable | +| [TaskUpdatedNotification](./system-notifications#tasks) | `AssignmentUpdated` event | configurable | +| [TaskAssignedToAuthorizableNotification](./system-notifications#tasks) | `AssignmentCreated` event | `database,mail` | +| [PaymentCompletedNotification](./system-notifications#payment) | `PaymentCompleted` event | configurable | +| [PaymentFailedNotification](./system-notifications#payment) | `PaymentFailed` event | `database,mail` | +| [ChatableUnreadNotification](./system-notifications#chat) | `UnreadChatMessage` event | configurable | +| [FeatureNotification](./feature-notification) | Abstract base — not dispatched directly | — | +| [LogNotification](./system-notifications#log) | Monolog handler | `mail` | + +→ [FeatureNotification base class](./feature-notification) +→ [System Notifications reference](./system-notifications) + +--- + +## Notification Model + +The `SystemNotification` module stores database-channel notifications in a `notifications` table via the `Notification` Eloquent model. + +``` +notifications table +├── id (UUID string, non-incrementing) +├── type (fully-qualified notification class name) +├── notifiable_type / notifiable_id (polymorphic) +├── data (JSON — subject, message, htmlMessage, redirector, …) +└── read_at (nullable datetime) +``` + +Appended attributes on the model: `is_read`, `is_mine`, `subject`, `message`, `html_message`, `redirector`, `has_redirector`, `redirector_text`. + +--- + +## Flow: Event → Listener → Notification + +``` +[Model saved / state changed / payment event] + │ + ▼ + SystemNotification Event dispatched + (e.g. ModelUpdated, StateableUpdated) + │ + ▼ + Listener::handle($event) + (e.g. ModelListener, StateableListener) + │ + ├─► notification->notify(...) [database channel] + │ stored in notifications table + │ + └─► Notification::route('mail', ...) [mail channel] + queued via modularity.notifications.mail_queue +``` + +--- + +## Configuration + +All system-notification behaviour is governed by `config/modularity.php` under the `notifications` key: + +| Key | Description | +|-----|-------------| +| `modularity.mail.enabled` | Master switch — disables all mail from `Listener::handle()` when `false` | +| `modularity.notifications.mail_connection` | Queue connection used for mail channel | +| `modularity.notifications.database_connection` | Queue connection used for database channel | +| `modularity.notifications.mail_queue` | Queue name for mail jobs | +| `modularity.notifications.database_queue` | Queue name for database notification jobs | +| `modularity.notifications.{ClassName}.channels` | Per-class channel override (comma-separated string) | +| `modularity.notifications.authorizable.channels` | Channel override for `TaskAssignedToAuthorizableNotification` | diff --git a/docs/src/pages/system-reference/backend/notifications/system-notifications.md b/docs/src/pages/system-reference/backend/notifications/system-notifications.md new file mode 100644 index 000000000..08a4b9443 --- /dev/null +++ b/docs/src/pages/system-reference/backend/notifications/system-notifications.md @@ -0,0 +1,275 @@ +--- +sidebarPos: 3 +sidebarTitle: System Notifications +--- + +# System Notifications + +`Modules\SystemNotification\Notifications\` + +Fourteen concrete notification classes bundled in the `SystemNotification` module. All extend [`FeatureNotification`](./feature-notification) except the standalone `ModelDeletedNotification`, `ModelRestoredNotification`, `ModelForceDeletedNotification`, and `LogNotification`, which extend Laravel's `Notification` directly. + +Every class implements `ShouldQueue` and uses the `Queueable` trait. + +--- + +## Event → Listener → Notification Map + +The `NotificationServiceProvider` wires up each event to its listener: + +| Event | Listener | Notification dispatched | +|-------|----------|------------------------| +| `ModelCreated` | `ModelListener` | `ModelCreatedNotification` | +| `ModelUpdated` | `ModelListener` | `ModelUpdatedNotification` | +| `ModelRestored` | `ModelListener` | `ModelRestoredNotification` | +| `ModelDeleted` | `ModelForceDeletedListener` | `ModelDeletedNotification` | +| `ModelForceDeleted` | `ModelForceDeletedListener` | `ModelForceDeletedNotification` | +| `StateableUpdated` | `StateableListener` | `StateableUpdatedNotification` | +| `AssignmentCreated` | `AssignableListener` | `TaskCreatedNotification`, `TaskAssignedToAuthorizableNotification` | +| `AssignmentUpdated` | `AssignableListener` | `TaskUpdatedNotification` | +| `PaymentCompleted` | `PaymentListener` | `PaymentCompletedNotification` | +| `PaymentFailed` | `PaymentListener` | `PaymentFailedNotification` | + +All listeners implement `ShouldHandleEventsAfterCommit`, so notifications are dispatched only after the database transaction commits. + +--- + +## Model Lifecycle + +### ModelCreatedNotification + +Fired when any model is created. + +```php +new ModelCreatedNotification($model) +``` + +**Default channels:** `mail` +**Mail subject:** `"New {Model Headline}"` +**Mail body:** `"A new {headline} was created called '{title}'."` + +### ModelUpdatedNotification + +Fired when any model is updated. + +```php +new ModelUpdatedNotification($model) +``` + +**Default channels:** `mail` +**Mail subject:** `"{Model Headline} Updated"` +**Mail body:** `"The {headline} '{title}' has been updated."` + +### ModelDeletedNotification + +Fired when a model is soft-deleted. + +```php +new ModelDeletedNotification($model, array $modelData) +``` + +**Default channels:** `mail` + +`$modelData` is a pre-serialized snapshot of the model passed by the listener (the model may no longer be fully accessible after deletion). + +### ModelRestoredNotification + +Fired when a soft-deleted model is restored. + +```php +new ModelRestoredNotification($model) +``` + +**Default channels:** `mail` +**Mail subject:** `"{Model Headline} Restored"` + +### ModelForceDeletedNotification + +Fired when a model is permanently deleted. + +```php +new ModelForceDeletedNotification($model, array $modelData) +``` + +**Default channels:** `mail` + +Like `ModelDeletedNotification`, accepts a pre-serialized `$modelData` snapshot. + +--- + +## Stateable {#stateable} + +### StateableUpdatedNotification + +Fired when a model transitions between states via the `HasStateable` trait. + +```php +new StateableUpdatedNotification($model, $newState, $oldState) +``` + +Extends `FeatureNotification`. + +| Argument | Description | +|----------|-------------| +| `$model` | The model whose state changed | +| `$newState` | The state the model transitioned **to** | +| `$oldState` | The state the model transitioned **from** | + +**Default channels:** configurable via `config('modularity.notifications.StateableUpdatedNotification.channels')` +**Mail subject:** `"{Model Headline} Status Changed"` +**Mail body:** `"The status of the {headline} '{title}' has been changed to {state}."` +**HTML body:** appends `$model->state_formatted` to the plain-text message + +The listener notifies `$model->creator` if the creator relationship exists: + +```php +if ($model->creator) { + $model->creator->notify(new StateableUpdatedNotification($model, $newState, $oldState)); +} +``` + +--- + +## Tasks {#tasks} + +### TaskCreatedNotification + +Fired when a new `Assignment` is created. + +```php +new TaskCreatedNotification(Assignment $model) +``` + +Extends `FeatureNotification`. +**Default channels:** configurable + +### TaskUpdatedNotification + +Fired when an existing `Assignment` is updated. + +```php +new TaskUpdatedNotification(Assignment $model) +``` + +Extends `FeatureNotification`. +**Default channels:** configurable + +### TaskAssignedToAuthorizableNotification + +Fired when a task is assigned to an authorizable entity (a user with role-based access). + +```php +new TaskAssignedToAuthorizableNotification($model) +``` + +Extends `FeatureNotification`. +**Default channels:** `database,mail` (read from `config('modularity.notifications.authorizable.channels', 'database,mail')`) + +--- + +## Payment {#payment} + +### PaymentCompletedNotification + +Fired when a payment completes successfully. + +```php +new PaymentCompletedNotification(Payment $model) +``` + +Extends `FeatureNotification`. +**Default channels:** configurable + +### PaymentFailedNotification + +Fired when a payment fails. + +```php +new PaymentFailedNotification(Payment $model) +``` + +Extends `FeatureNotification`. +**Hard-coded default channels:** `['database', 'mail']` + +--- + +## Chat {#chat} + +### ChatableUnreadNotification + +Fired when there are unread chat messages for a `Chatable` model. + +```php +new ChatableUnreadNotification(Chat $model) +``` + +The constructor resolves the **chatable** (the parent model) from the `Chat` instance: + +```php +parent::__construct($model->chatable); +``` + +Extends `FeatureNotification` and also implements `AfterSendable`. +**Default channels:** configurable + +--- + +## Log {#log} + +### LogNotification + +Dispatched by the Modularous Monolog handler when a critical log event occurs. + +```php +new LogNotification(LogRecord $record) +``` + +Does **not** extend `FeatureNotification` — extends Laravel's `Notification` directly. +**Default channels:** `mail` + +The constructor extracts only serializable data from the `LogRecord`: + +```php +$this->logData = [ + 'level' => $record->level->name, + 'message' => $record->message, + 'context' => $this->sanitizeContext($record->context), + 'datetime' => $record->datetime->format('Y-m-d H:i:s'), + 'channel' => $record->channel, +]; +``` + +--- + +## Customising System Notifications + +All `FeatureNotification` subclasses support the full [callback system](./feature-notification#callback-system). Register callbacks early in the application lifecycle (e.g. `AppServiceProvider::boot()`): + +```php +use Modules\SystemNotification\Notifications\ModelCreatedNotification; +use Modules\SystemNotification\Notifications\FeatureNotification; + +// Override the subject for one notification type +ModelCreatedNotification::createMailSubject( + fn($notifiable, $model, $default) => "New record: {$model->name}" +); + +// Override the title field resolution globally +FeatureNotification::createModelTitleField( + fn($model) => $model->display_name ?? $model->name ?? $model->id +); +``` + +### Per-class channel configuration + +```php +// config/modularity.php +'notifications' => [ + \Modules\SystemNotification\Notifications\ModelCreatedNotification::class => [ + 'channels' => 'mail,database', + ], + \Modules\SystemNotification\Notifications\StateableUpdatedNotification::class => [ + 'channels' => 'database', + ], +], +``` diff --git a/docs/src/pages/system-reference/backend/overview.md b/docs/src/pages/system-reference/backend/overview.md new file mode 100644 index 000000000..4688f2b46 --- /dev/null +++ b/docs/src/pages/system-reference/backend/overview.md @@ -0,0 +1,586 @@ +--- +sidebarPos: 5 +sidebarTitle: Backend +--- + +# Backend + +Reference for every PHP layer that ships with Modularous. Sections below are ordered alphabetically — each one points at the directory under `src/` it documents and the matching reference page under `system-reference/backend/`. + +| Section | Source path | Reference | +|---------|-------------|-----------| +| [Activators](#activators) | `src/Activators/` | [Activators →](/system-reference/backend/activators/overview) | +| [Brokers](#brokers) | `src/Brokers/` | [Brokers →](/system-reference/backend/brokers/overview) | +| [Console Commands](#console-commands) | `src/Console/` | [Console guide →](/guide/console/overview) | +| [Controllers](#controllers) | `src/Http/Controllers/` | [Controllers →](/system-reference/backend/http/controllers/overview) | +| [Entities](#entities) | `src/Entities/` | [Entities →](/system-reference/backend/entities/overview) | +| [Entity Traits](#entity-traits) | `src/Entities/Traits/` | [Entity Traits →](/system-reference/backend/entity-traits/overview) | +| [Events & Listeners](#events-listeners) | `src/Events/`, `src/Listeners/` | [Events & Listeners →](/system-reference/backend/events/overview) | +| [Facades](#facades) | `src/Facades/` | [Facades →](/system-reference/backend/facades/overview) | +| [Form Requests](#form-requests) | `src/Http/Requests/` | [Form Requests →](/system-reference/backend/http/request/overview) | +| [Generators](#generators) | `src/Generators/` | [Generators →](/system-reference/backend/generators/overview) | +| [Helpers](#helpers) | `src/Helpers/` | [Helpers →](/system-reference/backend/helpers/overview) | +| [Middleware](#middleware) | `src/Http/Middleware/` | [Middleware →](/system-reference/backend/http/middleware/overview) | +| [Notifications](#notifications) | `src/Notifications/` | [Notifications →](/system-reference/backend/notifications/overview) | +| [Providers](#providers) | `src/Providers/` | [Providers →](/system-reference/backend/providers/overview) | +| [Repository Traits](#repository-traits) | `src/Repositories/Traits/` | [Repository Traits →](/system-reference/backend/repository-traits/overview) | +| [Scheduled Jobs](#scheduled-jobs) | `src/Schedulers/` | [Scheduled Jobs →](/system-reference/backend/scheduled-jobs/overview) | +| [Services](#services) | `src/Services/` | [Services →](/system-reference/backend/services/overview) | +| [Support](#support) | `src/Support/` | [Support →](/system-reference/backend/support/overview) | +| [View Composers](#view-composers) | `src/Http/ViewComposers/` | [View Composers →](/system-reference/backend/http/view-composers/overview) | + +--- + +## Architecture at a glance + +Modularous layers a Laravel package on top of `nWidart/laravel-modules` and adds a CRUD-aware admin pipeline that every module inherits. The backend is organised as a stack of composable layers — bootstrap on top, persistence at the bottom — with cross-cutting services sitting alongside. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Bootstrap Providers · Activators · Facades · Helpers │ +│ ────────────────────────────────────────────── │ +│ HTTP entry Middleware → Form Requests → Controllers │ +│ ────────────────────────────────────────────── │ +│ Domain Repositories ↔ Repository Traits │ +│ Entities ↔ Entity Traits │ +│ ────────────────────────────────────────────── │ +│ Reactions Events → Listeners → Notifications │ +│ ────────────────────────────────────────────── │ +│ Background Scheduled Jobs · Console Commands │ +│ ────────────────────────────────────────────── │ +│ Cross-cutting Services · Support · View Composers · Brokers │ +│ Generators (build-time only) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Typical request lifecycle + +A normal admin CRUD request walks through most of these layers in order: + +1. **Providers** — `ModularityProvider` boots child providers (`BaseServiceProvider`, `RouteServiceProvider`, `AuthServiceProvider`, …) and binds every container entry the rest of the stack depends on. +2. **Activators** — `ModularityActivator` and `ModuleActivator` short-circuit the request if the target module or route is disabled. +3. **Middleware** — the `web` / `admin` / `api` pipelines run guards (`Authenticate`, `Authorization`, `Hostable`, `Language`, `Navigation`, `HandleInertiaRequests`, …). +4. **Form Request** — a `BaseFormRequest` subclass authorizes the user and validates input before the controller method runs. +5. **Controller** — `BaseController` resolves the active route, applies traits (`ManageIndexAjax`, `ManageInertia`, `ManagePrevious`, `ManageSingleton`, `ManageTranslations`), renders the form schema, and delegates persistence to `$this->repository`. +6. **Repository (+ Traits)** — the base `Repository` invokes every applicable [repository trait](#repository-traits) (`prepareFieldsBeforeSave{Trait}`, `afterSave{Trait}`, …) around the Eloquent `save()`. +7. **Entity (+ Traits)** — Eloquent models compose [entity traits](#entity-traits) for slugs, positions, soft-delete, translations, media, files, repeaters, blocks, processes, state, payments, presenters, and broadcasting context. +8. **Events & Listeners** — model lifecycle and auth events fire; `Listener::handle()` resolves the matching `{EventName}Notification` and dispatches it when `modularity.mail.enabled = true`. +9. **View Composers** — for Inertia/Blade responses, composers inject shared props (current user, navigation, uploader configs, localization, URLs). +10. **Response** — Inertia/JSON/Blade payload returns to the client; broadcasted events propagate over Echo channels. + +### How the layers reference each other + +| If you are working on… | You typically touch… | +|------------------------|----------------------| +| A new admin route | [Providers](#providers) → [Generators](#generators) → [Controllers](#controllers) → [Form Requests](#form-requests) → [Entities](#entities) → [Repository Traits](#repository-traits) | +| Authentication / registration | [Middleware](#middleware) → [Controllers](#controllers) (Auth) → [Brokers](#brokers) → [Notifications](#notifications) → [Events & Listeners](#events-listeners) | +| Caching / invalidation | [Services](#services) (`ModularityCacheService`, `CacheRelationshipGraph`) → [Repository Traits](#repository-traits) (Logic) → [Console Commands](#console-commands) (`cache:*`) | +| File / media uploads | [Controllers](#controllers) (Filepond / FileLibrary / MediaLibrary) → [Form Requests](#form-requests) → [Services](#services) (FilepondManager, FileLibrary, MediaLibrary, Uploader) → [Entity Traits](#entity-traits) (HasFileponds, HasFiles, HasImages) | +| Multi-tenant routing | [Providers](#providers) (`RouteServiceProvider`) → [Support](#support) (`HostRouting`) → [Middleware](#middleware) (`Hostable`) → [Facades](#facades) (`HostRouting`) | +| Real-time / chat | [Events & Listeners](#events-listeners) → [Notifications](#notifications) → [Services](#services) (BroadcastManager) → [Scheduled Jobs](#scheduled-jobs) (ChatableScheduler) | + +→ Architecture deep-dives also live under the [guides](/guide/architecture/overview) — cache, broadcasting, host routing, and module conventions are documented there. + +--- + +## Activators + +**Directory**: `src/Activators/` · **Namespace**: `Unusualify\Modularity\Activators` + +Activators persist and resolve enable/disable state. Modularous splits activation into two layers — module-level (whether a module loads at all) and route-level (whether a specific route action inside a module is enabled). + +| Class | Persisted in | Purpose | +|-------|--------------|---------| +| [`ModularityActivator`](/system-reference/backend/activators/modularity-activator) | `modules_statuses.json` + cache | Stores and resolves module-level statuses (`enabled` / `disabled`) | +| [`ModuleActivator`](/system-reference/backend/activators/module-activator) | `routes_statuses.json` per module | Stores and resolves route-level statuses inside a module | + +**CLI integration**: [`route:disable`](/guide/console/module/route-disable), [`route:enable`](/guide/console/module/route-enable), [`route:status`](/guide/console/module/route-status) read and mutate these files. + +→ [Activators reference](/system-reference/backend/activators/overview) + +--- + +## Brokers + +**Directory**: `src/Brokers/` · **Namespace**: `Unusualify\Modularity\Brokers` + +Powers the email-verification-based registration flow behind the [`Register` facade](/system-reference/backend/facades/register). Mirrors Laravel's password broker design, adapted for verification tokens. + +| Class | Role | +|-------|------| +| [`RegisterBroker`](/system-reference/backend/brokers/register-broker) | Executes verification-link and registration-token operations | +| [`RegisterBrokerManager`](/system-reference/backend/brokers/register-broker-manager) | Resolves named broker instances and selects the default broker | +| [`TokenRepositoryInterface`](/system-reference/backend/brokers/token-repository-interface) | Contract for email-based token repository methods | + +**Flow**: controller → `Register::broker()` → `RegisterBrokerManager` → `RegisterBroker` → `TokenRepositoryInterface`. + +→ [Brokers reference](/system-reference/backend/brokers/overview) + +--- + +## Console Commands + +**Directory**: `src/Console/` · **Namespace**: `Unusualify\Modularity\Console` + +Discovered via `CommandDiscovery::discover()` in `BaseServiceProvider`. Every command extends a `BaseCommand`, which adds module-aware option resolution and consistent output formatting on top of `Illuminate\Console\Command`. + +| Category | Path | Commands | +|----------|------|----------| +| Cache | `Console/Cache/` | [`cache:clear`](/guide/console/cache/cache-clear), [`cache:list`](/guide/console/cache/cache-list), [`cache:warm`](/guide/console/cache/cache-warm), [`cache:stats`](/guide/console/cache/cache-stats), [`cache:versions`](/guide/console/cache/cache-versions), [`cache:graph`](/guide/console/cache/cache-graph) | +| Coverage | `Console/Coverage/` | `coverage:report`, `coverage:methods` | +| Docs | `Console/Docs/` | Docs scaffolding helpers | +| Flush | `Console/Flush/` | [`flush`](/guide/console/flush/flush), [`flush:filepond`](/guide/console/flush/flush-filepond), [`flush:sessions`](/guide/console/flush/flush-sessions) | +| Make | `Console/Make/` | `make:controller`, `make:controller-api`, `make:event`, `make:feature`, `make:input-hydrate`, `make:laravel-test`, `make:listener`, `make:migration`, `make:model`, `make:module`, `make:operation`, `make:repository`, `make:repository-trait`, `make:request`, `make:route`, `make:route-permissions`, `make:stubs`, `make:theme`, `make:vue-input`, `make:vue-test` | +| Migration | `Console/Migration/` | `migrate`, `migrate:refresh`, `migrate:rollback` | +| Module | `Console/Module/` | `module:fix`, `module:remove`, `route:disable`, `route:enable`, [`route:status`](/guide/console/module/route-status) | +| Operations | `Console/Operations/` | Workflow / batch-operation runners | +| Setup | `Console/Setup/` | `create-superadmin`, [`create:database`](/guide/console/setup/create-database), `install`, `setup:dev` | +| Sync | `Console/Sync/` | [`sync:states`](/guide/console/sync/sync-states), [`sync:translations`](/guide/console/sync/sync-translations) | +| Top-level | `Console/` | [`build`](/guide/console/build), `composer:merge`, `composer:scripts`, `db:check-collation`, `dev`, [`pint`](/guide/console/pint), `refresh`, `replace:regex`, `version` | +| Update | `Console/Update/` | Module-update commands | + +→ Per-command usage is documented in the [Console guide](/guide/console/overview). + +--- + +## Controllers + +**Directory**: `src/Http/Controllers/` · **Namespace**: `Unusualify\Modularity\Http\Controllers` + +The controller hierarchy is **CoreController → PanelController → BaseController**. Modules extend `BaseController` and configure form schemas, with each layer adding more module-aware behaviour. + +| Layer | Purpose | +|-------|---------| +| [`CoreController`](/system-reference/backend/http/controllers/core-controller) | Base HTTP controller — shared response helpers | +| [`PanelController`](/system-reference/backend/http/controllers/panel-controller) | Route/model resolution, index options, authorization, `$this->repository` | +| [`BaseController`](/system-reference/backend/http/controllers/base-controller) | View prefix, form schema, index/create/edit flow, `setupFormSchema()` | + +**`BaseController` traits**: `ManageIndexAjax`, `ManageInertia`, `ManagePrevious`, `ManageSingleton`, `ManageTranslations`. + +**Request flow**: `preload()` → `addWiths()` → `setupFormSchema()` → `index()` / `create()` / `edit()` → `respondToIndexAjax()` for AJAX. + +**Concrete controllers** (selection): `ApiController`, `ChatController`, `DashboardController`, `FileLibraryController`, `FilepondController`, `MediaLibraryController`, `MetricController`, `ProcessController`, `ProfileController`, `TagController`, `UiPreferencesController`, plus auth-scoped controllers under `Http/Controllers/Auth/`. + +→ [Full Controllers reference](/system-reference/backend/http/controllers/overview) + +--- + +## Entities + +**Directory**: `src/Entities/` · **Namespace**: `Unusualify\Modularity\Entities` + +~30 Eloquent models for users, content, files, processes, state, and communication, plus supporting `Casts/`, `Enums/`, `Interfaces/`, `Mutators/`, `Observers/`, `Scopes/`, `Traits/`, and `Translations/` subfolders. Module-generated models extend `Model` and gain soft-deletes, tagging, caching, presenter support, and trait composition out of the box. + +| Group | Models | Reference | +|-------|--------|-----------| +| **Base classes** | Model, Revision | [Model →](/system-reference/backend/entities/model) · [Revision →](/system-reference/backend/entities/revision) | +| **Communication** | Chat, ChatMessage | [Chat →](/system-reference/backend/entities/chat) · [ChatMessage →](/system-reference/backend/entities/chat-message) | +| **Content building blocks** | Block, Repeater, Tag, Tagged | [Block →](/system-reference/backend/entities/block) · [Repeater →](/system-reference/backend/entities/repeater) · [Tag →](/system-reference/backend/entities/tag) | +| **Files & Media** | File, Filepond, Media, TemporaryFilepond | [File →](/system-reference/backend/entities/file) · [Filepond →](/system-reference/backend/entities/filepond) · [Media →](/system-reference/backend/entities/media) | +| **Other** | Feature, NestedsetCollection, RelatedItem | [Feature →](/system-reference/backend/entities/feature) · [NestedsetCollection →](/system-reference/backend/entities/nestedset-collection) · [RelatedItem →](/system-reference/backend/entities/related-item) | +| **Process & Workflow** | Assignment, Authorization, CreatorRecord, Process, ProcessHistory | [Assignment →](/system-reference/backend/entities/assignment) · [Authorization →](/system-reference/backend/entities/authorization) · [Process →](/system-reference/backend/entities/process) | +| **State & Data** | Setting, Singleton, Spread, State, Stateable | [Singleton →](/system-reference/backend/entities/singleton) · [State →](/system-reference/backend/entities/state) · [Stateable →](/system-reference/backend/entities/stateable) | +| **Users & Auth** | Company, Profile, User, UserOauth | [Company →](/system-reference/backend/entities/company) · [Profile →](/system-reference/backend/entities/profile) · [User →](/system-reference/backend/entities/user) | + +**Enums**: AssignmentStatus, PaymentStatus, Permission, ProcessStatus, RoleTeam, UserRole. + +→ [Full Entities reference](/system-reference/backend/entities/overview) + +--- + +## Entity Traits + +**Directory**: `src/Entities/Traits/` · **Namespace**: `Unusualify\Modularity\Entities\Traits` + +A library of traits (top-level + nested under `Auth/`, `Core/`, `Secondary/`) covering relationships, accessors, scopes, lifecycle hooks, and broadcasting context. Each trait composes onto an entity to add a self-contained behaviour without subclassing. + +| Group | Examples | Reference | +|-------|----------|-----------| +| **Auth** | CanRegister, HasOauth | [Auth →](/system-reference/backend/entity-traits/auth/overview) | +| **Core (top-level)** | HasPosition, HasPresenter, HasSlug, HasSpreadable, HasStateable, HasUuid | [Core →](/system-reference/backend/entity-traits/core/overview) | +| **Media** | HasFileponds, HasFiles, HasImages | [Media →](/system-reference/backend/entity-traits/media/overview) | +| **Model behavior** | HasPosition, HasPresenter, HasSlug, HasSpreadable, HasStateable, HasUuid | [Model behavior →](/system-reference/backend/entity-traits/model-behavior/overview) | +| **Payment** | HasPayment, HasPriceable | [Payment →](/system-reference/backend/entity-traits/payment/overview) | +| **Processes** | HasProcesses, Processable | [Processes →](/system-reference/backend/entity-traits/processes/overview) | +| **Relationships** | Assignable, Chatable, HasAuthorizable, HasCreator | [Relationships →](/system-reference/backend/entity-traits/relationships/overview) | +| **Repeaters** | HasRepeaters | [Repeaters →](/system-reference/backend/entity-traits/repeaters/overview) | +| **Secondary** | HasBlocks, HasNesting, HasRelated, HasRevisions | [Secondary →](/system-reference/backend/entity-traits/secondary/overview) | +| **Singletons** | IsHostable, IsSingular | [Singletons →](/system-reference/backend/entity-traits/singletons/overview) | +| **Translation** | HasTranslation, IsTranslatable | [Translation →](/system-reference/backend/entity-traits/translation/overview) | + +→ [Full Entity Traits reference](/system-reference/backend/entity-traits/overview) + +--- + +## Events & Listeners + +**Directory**: `src/Events/`, `src/Listeners/` · **Namespace**: `Unusualify\Modularity\Events`, `Unusualify\Modularity\Listeners` + +Events fire at lifecycle boundaries (model created/updated, user registered, state changed). Listeners react and, when mail is enabled, dispatch the matching notification. + +| Class | Role | Page | +|-------|------|------| +| [`Listener`](/system-reference/backend/events/listener) | Abstract base for listeners — resolves notification class from event name | Class reference | +| [`ModelEvent`](/system-reference/backend/events/model-event) | Abstract base — composes `EventChanges`, `EventStateable`, `EventUrls`, `EventUser` traits and provides broadcasting defaults | Class reference | +| [User events](/system-reference/backend/events/user-events) | `ModularityUserRegistered`, `ModularityUserRegistering`, `ModularityUserVerification`, `VerifiedEmailRegister` | Auth events | + +**Event traits** (under `events/traits/`): [`EventChanges`](/system-reference/backend/events/traits/event-changes), [`EventStateable`](/system-reference/backend/events/traits/event-stateable), [`EventUrls`](/system-reference/backend/events/traits/event-urls), [`EventUser`](/system-reference/backend/events/traits/event-user). Each is auto-set up in `ModelEvent`'s constructor and its public properties are serialized into broadcast payloads. + +→ [Full Events reference](/system-reference/backend/events/overview) +→ Real-time setup, Echo integration, testing, and troubleshooting are covered in the [Broadcasting guide](/guide/broadcasting/overview). + +--- + +## Facades + +**Directory**: `src/Facades/` · **Namespace**: `Unusualify\Modularity\Facades` + +18 Laravel facades providing static-style access to bound services. Each facade aliases a container entry to a concrete service class. + +**Selected facades**: [`Coverage`](/system-reference/backend/facades/coverage), [`CurrencyExchange`](/system-reference/backend/facades/currency-exchange), [`Filepond`](/system-reference/backend/facades/filepond), [`HostRouting`](/system-reference/backend/facades/host-routing), [`MigrationBackup`](/system-reference/backend/facades/migration-backup), [`Modularity`](/system-reference/backend/facades/modularity), [`ModularityCache`](/system-reference/backend/facades/modularity-cache), [`ModularityRoutes`](/system-reference/backend/facades/modularity-routes), [`ModularityVite`](/system-reference/backend/facades/modularity-vite), [`Navigation`](/system-reference/backend/facades/navigation), [`Redirect`](/system-reference/backend/facades/redirect), [`Register`](/system-reference/backend/facades/register), [`RelationshipGraph`](/system-reference/backend/facades/relationship-graph), [`Utm`](/system-reference/backend/facades/utm). + +→ [Full Facades reference](/system-reference/backend/facades/overview) + +--- + +## Form Requests + +**Directory**: `src/Http/Requests/` · **Namespace**: `Unusualify\Modularity\Http\Requests` + +Form Request classes that validate, authorize, and shape incoming requests for module endpoints. Extend Laravel's `FormRequest` with module-aware authorization and translation hooks. + +| Class | Purpose | +|-------|---------| +| [`BaseFormRequest`](/system-reference/backend/http/request/base-form-request) | Shared validation/authorization plumbing | +| [`FileRequest`](/system-reference/backend/http/request/file-request) | File upload validation | +| [`MediaRequest`](/system-reference/backend/http/request/media-request) | Media upload validation | +| [`OAuthRequest`](/system-reference/backend/http/request/oauth-request) | OAuth callback payload validation | +| [`Request`](/system-reference/backend/http/request/request) | Generic module-aware base form request | +| [`StorePermissionRequest`](/system-reference/backend/http/request/store-permission-request) | Permission creation | +| [`StoreRoleRequest`](/system-reference/backend/http/request/store-role-request) | Role creation | + +→ [Full Form Requests reference](/system-reference/backend/http/request/overview) + +--- + +## Generators + +**Directory**: `src/Generators/` · **Namespace**: `Unusualify\Modularity\Generators` + +Scaffolding engine behind `make:*` and `make:route` commands. Produces the full PHP and JS file set for new module routes plus test scaffolding for both backend and frontend. + +``` +Generator (abstract) ← NwidartGenerator + ReplacementTrait +├── RouteGenerator ← full-stack route scaffolding (primary) +├── StubsGenerator ← stub-only regeneration (fix/patch) +├── VueTestGenerator ← Vitest/Jest test scaffolding +└── LaravelTestGenerator ← PHPUnit test scaffolding +``` + +| Generator | Responsibility | +|-----------|---------------| +| [`Generator`](/system-reference/backend/generators/generator) | Abstract base — module resolution, config path helpers | +| [`LaravelTestGenerator`](/system-reference/backend/generators/laravel-test-generator) | PHPUnit Unit or Feature test scaffolding | +| [`RouteGenerator`](/system-reference/backend/generators/route-generator) | Full set of files for a new module route (model, migration, controller, repository, request, translations, permissions) | +| [`StubsGenerator`](/system-reference/backend/generators/stubs-generator) | Selective stub regeneration with `only` / `except` lists | +| [`VueTestGenerator`](/system-reference/backend/generators/vue-test-generator) | Vue component / composable / utility / store test scaffolding | + +→ [Full Generators reference](/system-reference/backend/generators/overview) + +--- + +## Helpers + +**Directory**: `src/Helpers/` · **Namespace**: global functions + +15 PHP helper files exposing 100+ global functions. Loaded by Composer autoload, available everywhere. + +| File | What it covers | +|------|----------------| +| [`array`](/system-reference/backend/helpers/array) | Array shape transforms | +| [`column`](/system-reference/backend/helpers/column) | Column metadata helpers | +| [`component`](/system-reference/backend/helpers/component) | Frontend component resolution | +| [`composer`](/system-reference/backend/helpers/composer) | Composer JSON parsing | +| [`connector`](/system-reference/backend/helpers/connector) | External-service connector helpers | +| [`db`](/system-reference/backend/helpers/db) | Schema introspection (`hasColumn`, `hasIndex`, etc.) | +| [`format`](/system-reference/backend/helpers/format) | Currency / date / number formatting | +| [`front`](/system-reference/backend/helpers/front) | Frontend URL/asset helpers | +| [`i18n`](/system-reference/backend/helpers/i18n) | Locale and translation helpers | +| [`input`](/system-reference/backend/helpers/input) | Input schema / hydrate helpers | +| [`media`](/system-reference/backend/helpers/media) | Image URL / disk resolution | +| [`migrations`](/system-reference/backend/helpers/migrations) | Migration build helpers | +| [`module`](/system-reference/backend/helpers/module) | `modularityConfig()`, `module_path()`, `currentModule()` | +| [`router`](/system-reference/backend/helpers/router) | Route resolution helpers | +| [`sources`](/system-reference/backend/helpers/sources) | Module path discovery | + +→ [Full Helpers reference](/system-reference/backend/helpers/overview) + +--- + +## Middleware + +**Directory**: `src/Http/Middleware/` · **Namespace**: `Unusualify\Modularity\Http\Middleware` + +14 middleware classes registered via `BaseServiceProvider` and grouped into pipelines for admin, auth, and API routes. + +| Middleware | Purpose | +|-----------|---------| +| [`Authenticate`](/system-reference/backend/http/middleware/authenticate) | Modularous-aware auth guard | +| [`Authorization`](/system-reference/backend/http/middleware/authorization) | Permission/role enforcement | +| [`CompanyRegistration`](/system-reference/backend/http/middleware/company-registration) | Company onboarding gate | +| [`HandleInertiaRequests`](/system-reference/backend/http/middleware/handle-inertia-requests) | Inertia shared props | +| [`Hostable`](/system-reference/backend/http/middleware/hostable) | Host-based routing resolution | +| [`Impersonate`](/system-reference/backend/http/middleware/impersonate) | User impersonation gate | +| [`Language`](/system-reference/backend/http/middleware/language) | Locale resolution from URL/header | +| [`LoadLocalizedConfig`](/system-reference/backend/http/middleware/load-localized-config) | Locale-specific config loading | +| [`Log`](/system-reference/backend/http/middleware/log) | Request logging | +| [`Navigation`](/system-reference/backend/http/middleware/navigation) | Sidebar nav assembly | +| [`RedirectIfAuthenticated`](/system-reference/backend/http/middleware/redirect-if-authenticated) | Guest-only routes | +| [`Redirector`](/system-reference/backend/http/middleware/redirector) | Configured redirect rules | +| [`TeamsPermission`](/system-reference/backend/http/middleware/teams-permission) | Team-aware permission resolution | +| [`Utm`](/system-reference/backend/http/middleware/utm) | UTM parameter capture | + +→ [Full Middleware reference](/system-reference/backend/http/middleware/overview) + +--- + +## Notifications + +**Directory**: `src/Notifications/` · **Namespace**: `Unusualify\Modularity\Notifications` + +| Group | Classes | Page | +|-------|---------|------| +| **Auth notifications** | `EmailVerification`, `GeneratePasswordNotification`, `ResetPasswordNotification` | [Auth notifications →](/system-reference/backend/notifications/auth-notifications) | +| **Feature base** | `FeatureNotification` | [FeatureNotification →](/system-reference/backend/notifications/feature-notification) | +| **System notifications** | 11 system notification classes (model lifecycle, payments, assignments, chat, state changes) | [System notifications →](/system-reference/backend/notifications/system-notifications) | + +Notifications are dispatched by `Listener::handle()` when `modularity.mail.enabled = true` and the listener resolves a `{EventName}Notification` class. + +→ [Full Notifications reference](/system-reference/backend/notifications/overview) + +--- + +## Providers + +**Directory**: `src/Providers/` · **Namespace**: `Unusualify\Modularity\Providers` + +Service providers wire the package into the host Laravel app. Bind services, publish config, register routes, hook view composers, wire the scheduler, and discover commands. + +| Provider | Role | +|----------|------| +| [`AuthServiceProvider`](/system-reference/backend/providers/auth-service-provider) | Modularous policies and gates | +| [`BaseServiceProvider`](/system-reference/backend/providers/base-service-provider) | Core bindings, command discovery, view composers, scheduler, middleware aliases | +| [`CoverageServiceProvider`](/system-reference/backend/providers/coverage-service-provider) | Coverage analyzer + commands binding | +| [`ModularityProvider`](/system-reference/backend/providers/modularity-provider) | Top-level provider — registers all child providers | +| [`ModuleServiceProvider`](/system-reference/backend/providers/module-service-provider) | Per-module provider auto-generated for each module | +| [`RouteServiceProvider`](/system-reference/backend/providers/route-service-provider) | Module route registration with host/permission groups | +| [`ServiceProvider`](/system-reference/backend/providers/service-provider) | Abstract base with config publishing helpers | +| [`TelescopeServiceProvider`](/system-reference/backend/providers/telescope-service-provider) | Telescope integration when present | + +→ [Full Providers reference](/system-reference/backend/providers/overview) + +--- + +## Repository Traits + +**Directory**: `src/Repositories/Traits/` · **Namespace**: `Unusualify\Modularity\Repositories\Traits` + +Repository traits extend the base `Repository` class with domain-specific persistence logic. While [Entity Traits](/system-reference/backend/entity-traits/overview) define model-level behaviour, repository traits handle **how data flows in and out** — form field hydration, after-save side effects, table filters, caching. + +Every repository trait follows a naming convention that the base `Repository` discovers and invokes at the right lifecycle stage: + +| Convention | When called | +|------------|-------------| +| `setColumns{Trait}` | Boot — registers form input names this trait manages | +| `prepareFieldsBeforeCreate{Trait}` | Before insert | +| `prepareFieldsBeforeSave{Trait}` | Before any save | +| `beforeSave{Trait}` / `afterSave{Trait}` | Around the Eloquent `save()` call | +| `hydrate{Trait}` | Sets in-memory relationships before save (preview/validation) | +| `getFormFields{Trait}` | Populates form fields when editing | +| `afterDelete{Trait}` / `afterRestore{Trait}` | Soft-delete + restore hooks | +| `filter{Trait}` / `order{Trait}` | Index query scopes / ordering | +| `getTableFilters{Trait}` | Filter tab definitions for the data table UI | +| `getFormActions{Trait}` | Form action button definitions | + +| Group | Page | +|-------|------| +| Content (Blocks, Repeaters) | [Content →](/system-reference/backend/repository-traits/content) | +| Logic helpers (caching, schema, query building) | [Logic →](/system-reference/backend/repository-traits/logic/overview) | +| Media (Files, Images, Filepond) | [Media →](/system-reference/backend/repository-traits/media) | +| OAuth | [OAuth →](/system-reference/backend/repository-traits/oauth) | +| Payment | [Payment →](/system-reference/backend/repository-traits/payment) | +| Processes | [Processes →](/system-reference/backend/repository-traits/processes) | +| Relationships | [Relationships →](/system-reference/backend/repository-traits/relationships) | +| State | [State →](/system-reference/backend/repository-traits/state) | + +→ [Full Repository Traits reference](/system-reference/backend/repository-traits/overview) + +--- + +## Scheduled Jobs + +**Directory**: `src/Schedulers/` · **Namespace**: `Unusualify\Modularity\Schedulers` + +Background jobs auto-discovered from `src/Schedulers/*.php` via `CommandDiscovery` and registered against Laravel's `Schedule` inside `BaseServiceProvider::boot()` (no `Console\Kernel.php` is required in the host app). + +| Command | Class | Cadence | Purpose | +|---------|-------|---------|---------| +| `modularity:fileponds:scheduler` | [`FilepondsScheduler`](/system-reference/backend/scheduled-jobs/fileponds-scheduler) | Daily | Cleans up orphaned `temporary_fileponds` rows + their files | +| `modularity:scheduler:chatable` | [`ChatableScheduler`](/system-reference/backend/scheduled-jobs/chatable-scheduler) | Every minute | Aggregates unread chat messages and dispatches `UnreadChatMessage` notifications | +| `telescope:prune` | (Laravel Telescope) | Daily | Prunes Telescope entries older than 168 hours | + +Both Modularous schedulers can also be run manually as Artisan commands. Output goes to the `scheduler` log channel; the host server only needs the standard `* * * * * php artisan schedule:run` cron entry. + +→ [Full Scheduled Jobs reference](/system-reference/backend/scheduled-jobs/overview) + +--- + +## Services + +**Directory**: `src/Services/` · **Namespace**: `Unusualify\Modularity\Services` + +Bound in the service container; injected via constructor or accessed through their dedicated [Facades](#facades). Each group has its own reference page. + +| Group | Members | Page | +|-------|---------|------| +| **Cache concerns** | CacheHelpers, CacheInvalidation, CacheTags | [Cache Concerns →](/system-reference/backend/services/cache-concerns/overview) | +| **Core services** | Assets, BroadcastManager, Connector, CoverageService, CurrencyExchangeService, FilepondManager, FileTranslation, MessageStage, MigrationBackup, ModularityCacheService, RedirectService, Translation, UtmParameters | [Services →](/system-reference/backend/services/overview) | +| **Currency** | NullCurrencyProvider, SystemPricingCurrencyProvider | [Currency →](/system-reference/backend/services/currency/overview) | +| **FileLibrary** | Disk, FileService | [FileLibrary →](/system-reference/backend/services/file-library/overview) | +| **MediaLibrary** | Glide, Imgix, Local, TwicPics drivers | [MediaLibrary →](/system-reference/backend/services/media-library/overview) | +| **Uploader** | SignAzureUpload, SignS3Upload, SignUploadListener | [Uploader →](/system-reference/backend/services/uploader/overview) | +| **View services** | ModularityNavigation, UComponent, UWidget, UWrapper | [View Services →](/system-reference/backend/services/view/overview) | + +**Cache support**: [`CacheRelationshipGraph`](/system-reference/backend/services/cache-relationship-graph) drives cross-entity cache invalidation. The CLI face is [`cache:graph`](/guide/console/cache/cache-graph). + +→ [Full Services reference](/system-reference/backend/services/overview) + +--- + +## Support + +**Directory**: `src/Support/` · **Namespace**: `Unusualify\Modularity\Support` + +Stateless utility classes used across the codebase for command discovery, schema parsing, route grouping, and host-based routing. + +| Class | Purpose | +|-------|---------| +| [`CommandDiscovery`](/system-reference/backend/support/command-discovery) | Scan glob paths and return instantiable `Command` FQCNs | +| [`CoverageAnalyzer`](/system-reference/backend/support/coverage-analyzer) | Parse Clover XML and report per-method coverage | +| [`Decomposers`](/system-reference/backend/support/decomposers) | Parse schema/relation/validation strings for generators | +| [`FileLoader`](/system-reference/backend/support/file-loader) | Translation file loader with multi-path support | +| [`Finder`](/system-reference/backend/support/finder) | Resolve model/repository by table name or route name | +| [`HostRouting / HostRouteRegistrar`](/system-reference/backend/support/host-routing) | Multi-tenant host-based route groups | +| [`Migrations\SchemaParser`](/system-reference/backend/support/migrations-schema-parser) | Render migration `$table->…` PHP from schema strings | +| [`ModularityRoutes`](/system-reference/backend/support/modularity-routes) | Route group options and middleware alias registration | +| [`ModularityVite`](/system-reference/backend/support/modularity-vite) | Vite integration for the Modularous asset manifest | +| [`RegexReplacement`](/system-reference/backend/support/regex-replacement) | Batch regex find-and-replace across a directory tree | + +→ [Full Support reference](/system-reference/backend/support/overview) + +--- + +## View Composers + +**Directory**: `src/Http/ViewComposers/` · **Namespace**: `Unusualify\Modularity\Http\ViewComposers` + +Inject shared variables into Blade / Inertia layouts. Wired in `BaseServiceProvider::registerViewComposers()`. + +| Composer | Views | Condition | +|----------|-------|-----------| +| [`ActiveNavigation`](/system-reference/backend/http/view-composers/active-navigation) | layouts with sidebar | Always | +| [`CurrentUser`](/system-reference/backend/http/view-composers/current-user) | `admin.*`, `{baseKey}::*` | `enabled.users-management` = true | +| [`FilesUploaderConfig`](/system-reference/backend/http/view-composers/files-uploader-config) | master/app-inertia layouts | `enabled.file-library` = true | +| [`Localization`](/system-reference/backend/http/view-composers/localization) | master/auth/app-inertia layouts | Always | +| [`MediasUploaderConfig`](/system-reference/backend/http/view-composers/medias-uploader-config) | master/app-inertia layouts | `enabled.media-library` = true | +| [`Urls`](/system-reference/backend/http/view-composers/urls) | `*` | Always | + +→ [Full View Composers reference](/system-reference/backend/http/view-composers/overview) + +--- + +## Cross-cutting concerns + +A few capabilities don't live in a single section — they thread through several layers at once. Subsections below are ordered alphabetically; use them as a map to find every place a given concern shows up. + +### Authentication, authorization & registration + +Auth is split between the request pipeline (middleware), the Spatie permission stack (gates/policies), and a verification-token broker for self-service registration. + +| Layer | Component | +|-------|-----------| +| Brokers | [`RegisterBroker`, `RegisterBrokerManager`, `TokenRepositoryInterface`](/system-reference/backend/brokers/overview) | +| Controllers | `Http/Controllers/Auth/*` | +| Entities & Traits | `Company`, `Profile`, `User`, `UserOauth`; `CanRegister`, `HasOauth` | +| Enums | `Permission`, `RoleTeam`, `UserRole` | +| Form Requests | `OAuthRequest`, `StorePermissionRequest`, `StoreRoleRequest` | +| Middleware | [`Authenticate`](/system-reference/backend/http/middleware/authenticate), [`Authorization`](/system-reference/backend/http/middleware/authorization), [`CompanyRegistration`](/system-reference/backend/http/middleware/company-registration), [`Impersonate`](/system-reference/backend/http/middleware/impersonate), [`RedirectIfAuthenticated`](/system-reference/backend/http/middleware/redirect-if-authenticated), [`TeamsPermission`](/system-reference/backend/http/middleware/teams-permission) | +| Notifications | `EmailVerification`, `GeneratePasswordNotification`, `ResetPasswordNotification` | +| Providers | [`AuthServiceProvider`](/system-reference/backend/providers/auth-service-provider) | + +### Caching + +Modularous ships a versioned, tag-aware cache built on top of Laravel's cache. Invalidation propagates through a relationship graph so editing a model also flushes anything that depends on it. + +| Layer | Component | +|-------|-----------| +| Bind / boot | `BaseServiceProvider` (registers `ModularityCacheService`, `CacheRelationshipGraph`) | +| CLI | [`cache:clear`](/guide/console/cache/cache-clear), [`cache:graph`](/guide/console/cache/cache-graph), [`cache:list`](/guide/console/cache/cache-list), [`cache:stats`](/guide/console/cache/cache-stats), [`cache:versions`](/guide/console/cache/cache-versions), [`cache:warm`](/guide/console/cache/cache-warm) | +| Concerns | [`CacheHelpers`, `CacheInvalidation`, `CacheTags`](/system-reference/backend/services/cache-concerns/overview) | +| Day-to-day API | [`ModularityCache`](/system-reference/backend/facades/modularity-cache) facade, [`CacheRelationshipGraph`](/system-reference/backend/services/cache-relationship-graph) | +| Repository hooks | [Repository Traits → Logic](/system-reference/backend/repository-traits/logic/overview) (`afterSave{Trait}`, `afterDelete{Trait}`) | + +→ Full deep-dive: [Caching guide](/guide/caching/overview). + +### File & media uploads + +Three independent upload pipelines share the same general shape (Form Request → Service → Entity Trait). + +| Pipeline | Controller | Service | Entity Trait | Repository Trait | +|----------|-----------|---------|--------------|------------------| +| Direct cloud uploads | — | [Uploader](/system-reference/backend/services/uploader/overview) (`SignAzureUpload`, `SignS3Upload`) | — | — | +| File library | `FileLibraryController` | [FileLibrary](/system-reference/backend/services/file-library/overview) | `HasFiles` | [Media](/system-reference/backend/repository-traits/media) | +| Filepond (chunked uploads) | `FilepondController` | [`FilepondManager`](/system-reference/backend/services/overview) | `HasFileponds` | [Media](/system-reference/backend/repository-traits/media) | +| Media library (image variants) | `MediaLibraryController` | [MediaLibrary](/system-reference/backend/services/media-library/overview) | `HasImages` | [Media](/system-reference/backend/repository-traits/media) | + +### Module scaffolding & generation + +Build-time only — these run from the CLI to produce or regenerate code. + +| Layer | Component | +|-------|-----------| +| CLI | `make:*` commands ([Console Commands](#console-commands)) | +| Generators | [`Generator`, `LaravelTestGenerator`, `RouteGenerator`, `StubsGenerator`, `VueTestGenerator`](#generators) | +| Support | [`Decomposers`](/system-reference/backend/support/decomposers), [`Migrations\SchemaParser`](/system-reference/backend/support/migrations-schema-parser), [`RegexReplacement`](/system-reference/backend/support/regex-replacement) | + +→ End-to-end walkthrough: [Module creation guide](/guide/modules/overview). + +### Multi-tenant / host-based routing + +When the same module needs to serve different domains with different middleware or permission groups. + +| Layer | Component | +|-------|-----------| +| Entity Traits | `IsHostable`, `IsSingular` | +| Facade | [`HostRouting`](/system-reference/backend/facades/host-routing) | +| Middleware | [`Hostable`](/system-reference/backend/http/middleware/hostable) | +| Provider | [`RouteServiceProvider`](/system-reference/backend/providers/route-service-provider) | +| Support | [`HostRouting / HostRouteRegistrar`](/system-reference/backend/support/host-routing), [`ModularityRoutes`](/system-reference/backend/support/modularity-routes) | + +### Real-time broadcasting + +Model lifecycle and chat events broadcast over Laravel Echo when broadcasting is enabled. + +| Layer | Component | +|-------|-----------| +| Base classes | [`Listener`](/system-reference/backend/events/listener), [`ModelEvent`](/system-reference/backend/events/model-event) (with [event traits](/system-reference/backend/events/overview)) | +| Entity Traits | `Chatable`, `HasProcesses`, `HasStateable`, `Processable` | +| Notifications | System notifications (model lifecycle, payments, assignments, chat, state changes) | +| Scheduled job | [`ChatableScheduler`](/system-reference/backend/scheduled-jobs/chatable-scheduler) | +| Service | `BroadcastManager` | + +→ Setup, channel naming, Echo client, testing, and troubleshooting: [Broadcasting guide](/guide/broadcasting/overview). + +--- + +→ Looking for something not covered above? Browse the [system reference index](/system-reference/overview) or jump straight into the per-section overview pages linked from each heading. diff --git a/docs/src/pages/system-reference/backend/providers/auth-service-provider.md b/docs/src/pages/system-reference/backend/providers/auth-service-provider.md new file mode 100644 index 000000000..2af0764d7 --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/auth-service-provider.md @@ -0,0 +1,92 @@ +--- +sidebarPos: 2 +sidebarTitle: AuthServiceProvider +--- + +# AuthServiceProvider + +**Class**: `Unusualify\Modularity\Providers\AuthServiceProvider` +**Source**: `src/Providers/AuthServiceProvider.php` +**Extends**: `Illuminate\Foundation\Support\Providers\AuthServiceProvider` +**Implements**: `Illuminate\Contracts\Support\DeferrableProvider` + +Registers authorization gates, configures Laravel Horizon access, and customises the email verification flow. All gate definitions are loaded dynamically from the permissions table — no hardcoded ability strings in application code. + +## `boot()` + +Guards the entire boot body behind two runtime checks: + +```php +if (exceptionalRunningInConsole() && database_exists() && Schema::hasTable(config('permission.table_names.permissions'))) +``` + +This ensures gate definitions are only applied when the database is available and the permissions table has been migrated. + +### Superadmin bypass + +```php +Gate::before(function (User $user, $ability) { + return $user->hasRole('superadmin') ? true : null; +}); +``` + +Any user with the `superadmin` role passes every gate check without further evaluation. + +### Static gates + +| Gate | Logic | +|------|-------| +| `dashboard` | User must have the `dashboard` permission | +| `impersonate` | User's role must be `superadmin` | + +### Dynamic gates + +Every row in the `permissions` table becomes a named gate: + +```php +foreach (Permission::all() as $permission) { + Gate::define($permission->name, function ($user) use ($permission) { + return $this->userHasPermission($user, [$permission->name]); + }); +} +``` + +### Horizon access + +```php +Horizon::auth(function ($request) { + return app()->environment('local') + || $request->user()->is_superadmin + || in_array($request->user()->email, [...]); +}); +``` + +Horizon is accessible in local environments or for superadmins and any email addresses in the whitelist. + +## `register()` + +### Custom email verification URL + +Generates a signed temporary URL pointing to `admin.verification.verify` instead of the default Laravel route: + +```php +VerifyEmail::createUrlUsing(function ($notifiable) { + return URL::temporarySignedRoute( + 'admin.verification.verify', + Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), + ['id' => $notifiable->getKey(), 'hash' => sha1($notifiable->getEmailForVerification())] + ); +}); +``` + +### Custom verification mail + +Overrides `VerifyEmail::toMailUsing()` to send a standard `MailMessage` with a localised subject and body. + +## Helper methods + +| Method | Description | +|--------|-------------| +| `authorize($user, $callback)` | Runs `$callback($user)` — thin wrapper keeping gate closures clean | +| `userHasRole($user, $roles)` | Checks if `$user->roles` is in the given array | +| `userHasPermission($user, $permissions)` | Checks if `$user->permissions` is in the given array | diff --git a/docs/src/pages/system-reference/backend/providers/base-service-provider.md b/docs/src/pages/system-reference/backend/providers/base-service-provider.md new file mode 100644 index 000000000..e500fb5d3 --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/base-service-provider.md @@ -0,0 +1,125 @@ +--- +sidebarPos: 3 +sidebarTitle: BaseServiceProvider +--- + +# BaseServiceProvider + +**Class**: `Unusualify\Modularity\Providers\BaseServiceProvider` +**Source**: `src/Providers/BaseServiceProvider.php` +**Extends**: [`ServiceProvider`](./service-provider) + +The core provider that handles all container bindings, macro registration, view composers, log channels, and the application scheduler. It is the heaviest provider in the stack and is registered by [`ModularityProvider`](./modularity-provider). + +## `register()` + +### Helper files + +All PHP files in `src/Helpers/` are required in sequence so global helper functions are available globally. + +### Config merging + +| Source | Merged into | +|--------|------------| +| `config/config.php` | `modularity.*` | +| `config/merges/*.php` | `modularity.{filename}.*` | +| `config/disks.php` | `filesystems.disks.*` | + +### Container bindings (singletons) + +| Binding key | Class | Description | +|-------------|-------|-------------| +| `RepositoryInterface` | `Modularity` | Overrides nwidart's `LaravelFileRepository` with the Modularity-extended implementation | +| `modularity` | alias for `Modularity` | Convenience alias | +| `modularity.navigation` | `ModularityNavigation` | Admin navigation service | +| `model.relation.namespace` | — | Eloquent relations namespace string | +| `model.relation.pattern` | — | Regex pattern derived from relation namespace | +| `unusualify.hosting` | `HostRouting` | Host-based routing helper | +| `unusualify.hostRouting` | `HostRouteRegistrar` | Host-based route registrar | +| `Filepond` | `FilepondManager` | Filepond upload manager | +| `currency.exchange` | `CurrencyExchangeService` | Currency exchange rates | +| `CurrencyProviderInterface` | Resolved at runtime | Uses `modularity.currency_provider` config; falls back to `SystemPricingCurrencyProvider` then `NullCurrencyProvider` | +| `modularity.relationship.graph` | `CacheRelationshipGraph` | Relationship dependency graph for cache invalidation | +| `modularity.cache` | `ModularityCacheService` | Package-level cache service | +| `migration.backup` | `MigrationBackup` | Migration backup utility | +| `modularity.redirect` | `RedirectService` | Redirect management | +| `modularity.utm` | `UtmParameters` | UTM parameter tracking | +| `auth.register` | `RegisterBrokerManager` | Auth registration broker | +| `inertia.middleware` | `HandleInertiaRequests` | Inertia request handler | + +### Aliases + +| Alias | Class | +|-------|-------| +| `ModularityVite` | `Facades\ModularityVite` | +| `GeoIP` | `Torann\GeoIP\Facades\GeoIP` | + +### Translation service + +Extends Laravel's `translation.loader` with a multi-path `FileLoader` that searches: +1. `vendor/laravel/framework/.../Translation/lang` +2. Package `lang/` +3. Application `lang/` (when not overridden by `modularity/lang/`) +4. `app['path.lang']` + +Extends `translator` with a custom `Translator` instance that reads from these paths in order. + +## `boot()` + +### Auth config validation + +When `enabled.users-management` is on and not running in console, validates that the Modularity auth guard, provider, and password config all exist in `config/auth`. Throws `AuthConfigurationException` with a descriptive message if any are missing. + +### Media and file service singletons + +| Condition | Binding | +|-----------|---------| +| `enabled.media-library` = true | `imageService` → class from `media_library.image_service` config | +| `enabled.file-library` = true | `fileService` → class from `file_library.file_service` config | + +Local disk URL is auto-configured when endpoint type is `local` and disk matches the package default. + +### Macros + +| Macro | Target | Description | +|-------|--------|-------------| +| `Str::modularitySlug()` | `Illuminate\Support\Str` | Slug with locale-aware dictionary from `slug-dictionary` translations | +| `Collection::recursive()` | `Illuminate\Support\Collection` | Recursively converts all nested arrays/objects to Collections | +| `Request::getCachedUserCurrency()` | `Illuminate\Support\Facades\Request` | Returns user's currency from session or the default pricing currency | + +### View composers + +Registered on `'*'` (all views) or specific layout views: + +| Composer | Views | Condition | +|----------|-------|-----------| +| Inline | `*` | Always — injects `BASE_KEY`, `MODULARITY_VIEW_NAMESPACE`, `SYSTEM_PACKAGE_VERSIONS` | +| [`Urls`](../http/view-composers/urls) | `*` | Always | +| [`CurrentUser`](../http/view-composers/current-user) | `admin.*`, `{baseKey}::*` | `enabled.users-management` = true | +| [`MediasUploaderConfig`](../http/view-composers/medias-uploader-config) | master/app-inertia layouts | `enabled.media-library` = true | +| [`FilesUploaderConfig`](../http/view-composers/files-uploader-config) | master/app-inertia layouts | `enabled.file-library` = true | +| [`Localization`](../http/view-composers/localization) | master/auth/app-inertia layouts | Always | +| Inline render flags | `admin.*`, `templates.*`, `{baseKey}::*` | Always — injects `renderForBlocks`, `renderForModal` | + +### Scheduler + +| Command | Schedule | +|---------|----------| +| `modularity:fileponds:scheduler --days=7` | Daily | +| `telescope:prune --hours=168` | Daily (appends to `logs/scheduler.log`) | +| `modularity:scheduler:chatable` | Every minute | + +### Log channels + +| Channel | Driver | Description | +|---------|--------|-------------| +| `modularity` | `monolog` with `ModularityLogHandler` | Package debug log; retention 14 days; level from `MODULARITY_LOG_LEVEL` env | +| `modularity-notification-failure` | `daily` | Notification failure log; `storage/logs/modularity-notification-failure.log`; 14-day retention | + +### Password reset URL + +Overrides Laravel's default reset URL to point to `admin.password.reset` named route. + +### `php artisan about` + +Adds a **Modularous** section to `php artisan about` output showing cache status, scan status, theme, URLs, vendor path, and version. diff --git a/docs/src/pages/system-reference/backend/providers/coverage-service-provider.md b/docs/src/pages/system-reference/backend/providers/coverage-service-provider.md new file mode 100644 index 000000000..549bdb15c --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/coverage-service-provider.md @@ -0,0 +1,75 @@ +--- +sidebarPos: 4 +sidebarTitle: CoverageServiceProvider +--- + +# CoverageServiceProvider + +**Class**: `Unusualify\Modularity\Providers\CoverageServiceProvider` +**Source**: `src/Providers/CoverageServiceProvider.php` +**Extends**: `Illuminate\Support\ServiceProvider` + +Registers the code-coverage analysis infrastructure. Exposes a low-level `CoverageAnalyzer` for parsing Clover XML reports and a high-level `CoverageService` singleton for querying coverage data. + +## `register()` + +### `coverage.analyzer` (transient) + +A new `CoverageAnalyzer` instance is created for each resolution. Accepts optional construction parameters: + +```php +app('coverage.analyzer', [ + 'cloverDir' => '/path/to/clover', + 'cloverName' => 'coverage-clover.xml', +]); +``` + +Falls back to `modularity-coverage.clover_dir` and `modularity-coverage.clover_name` config values, then to the package vendor path. + +### `coverage.service` (singleton) + +A single `CoverageService` instance shared across the application. Used by the `Coverage` facade. + +```php +app('coverage.service')->getCoverage(); // CoverageService facade +``` + +### Alias + +`CoverageService::class` is aliased to `coverage.service`, so type-hinted injection works: + +```php +public function __construct(CoverageService $coverage) { ... } +``` + +## `boot()` + +### Config publishing + +```bash +php artisan vendor:publish --tag=modularity-coverage-config +``` + +Publishes `config/coverage.php` → `config/modularity-coverage.php`. + +### Console commands + +When running in the console, discovers and registers all commands from `src/Console/Coverage/`: + +```php +CommandDiscovery::discover([__DIR__ . '/../Console/Coverage/*.php']) +``` + +## `provides()` + +Returns `['coverage.analyzer', 'coverage.service', CoverageService::class]`, allowing Laravel to defer loading this provider until one of these bindings is requested. + +## Configuration + +```php +// config/modularity-coverage.php +return [ + 'clover_dir' => base_path(), // directory containing the Clover XML file + 'clover_name' => 'coverage-clover.xml', // filename of the Clover report +]; +``` diff --git a/docs/src/pages/system-reference/backend/providers/modularity-provider.md b/docs/src/pages/system-reference/backend/providers/modularity-provider.md new file mode 100644 index 000000000..ea4389309 --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/modularity-provider.md @@ -0,0 +1,58 @@ +--- +sidebarPos: 5 +sidebarTitle: ModularityProvider +--- + +# ModularityProvider + +**Class**: `Unusualify\Modularity\Providers\ModularityProvider` +**Source**: `src/Providers/ModularityProvider.php` +**Extends**: [`ServiceProvider`](./service-provider) + +The single entry point for the entire Modularity package. Registers all internal and third-party providers in the correct order. Only this provider needs to be added to `config/app.php`. + +## Provider Boot Order + +```php +protected $providers = [ + // Third-party + GeoIPServiceProvider::class, + TimezoneServiceProvider::class, + + // Modularity internals + BaseServiceProvider::class, + ModuleServiceProvider::class, + RouteServiceProvider::class, + AuthServiceProvider::class, + CoverageServiceProvider::class, +]; +``` + +| Order | Provider | Purpose | +|-------|----------|---------| +| 1 | `GeoIPServiceProvider` | IP geolocation via `torann/geoip` | +| 2 | `TimezoneServiceProvider` | Timezone support via `camroncade/timezone` | +| 3 | [`BaseServiceProvider`](./base-service-provider) | Core bindings, commands, macros, view composers | +| 4 | [`ModuleServiceProvider`](./module-service-provider) | Per-module boot (migrations, views, lang, commands) | +| 5 | [`RouteServiceProvider`](./route-service-provider) | System and module routing | +| 6 | [`AuthServiceProvider`](./auth-service-provider) | Gates, Horizon auth, email verification | +| 7 | [`CoverageServiceProvider`](./coverage-service-provider) | Code-coverage analysis bindings | + +## Methods + +### `register()` + +Iterates `$providers` and calls `$this->app->register($provider)` for each. No direct bindings are made here — all container registrations are delegated to the individual providers. + +### `boot()` + +Runs the `booted` callback when `exceptionalRunningInConsole()` is true (i.e., the application is running in a console context that should behave like a web request). Currently a no-op placeholder for post-boot console initialisation. + +## Registration + +```php +// config/app.php +'providers' => [ + Unusualify\Modularity\Providers\ModularityProvider::class, +], +``` diff --git a/docs/src/pages/system-reference/backend/providers/module-service-provider.md b/docs/src/pages/system-reference/backend/providers/module-service-provider.md new file mode 100644 index 000000000..68086a65d --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/module-service-provider.md @@ -0,0 +1,77 @@ +--- +sidebarPos: 6 +sidebarTitle: ModuleServiceProvider +--- + +# ModuleServiceProvider + +**Class**: `Unusualify\Modularity\Providers\ModuleServiceProvider` +**Source**: `src/Providers/ModuleServiceProvider.php` +**Extends**: [`ServiceProvider`](./service-provider) +**Implements**: `Illuminate\Contracts\Support\DeferrableProvider` + +Bootstraps every enabled nwidart module. Iterates `Modularity::allEnabled()` and registers each module's providers, config, commands, migrations, views, Blade components, and translations. + +## `bootModules()` + +For each enabled module, the following steps run in order: + +### 1. Middleware aliases + +Calls `$module->createMiddlewareAliases()` to register any middleware defined in the module's configuration. + +### 2. Service providers + +Scans the module's provider directory for `*ServiceProvider.php` files and registers each one via `$this->app->register()`. + +### 3. Config + +Calls `$module->loadConfig()` to merge the module's config files. + +### 4. Commands + +Calls `$module->loadCommands()` to register the module's artisan commands. + +### 5. Migrations + +```php +$this->loadMigrationsFrom($module->getDirectoryPath($migration_folder)); +``` + +### 6. Views + +Loads views from the publishable override path first, then the module source path, under the module's snake-case namespace: + +```php +$this->loadViewsFrom( + array_merge($this->getPublishableViewPaths($module->getSnakeName()), [$sourcePath]), + $module->getSnakeName() +); +``` + +### 7. Blade components + +```php +Blade::componentNamespace($namespace, snakeCase($module_name)); +``` + +### 8. Translations + +Checks `lang/modules/{snake_name}` in the application root first (override). Falls back to the module's own `Resources/lang` directory. + +## Config paths + +The provider reads paths from nwidart's `GenerateConfigReader` so the folder layout is driven by the module generator config: + +| Key | Reader | Default path | +|-----|--------|-------------| +| Migrations | `migration` | `Database/Migrations` | +| Config | `config` | `Config` | +| Providers | `provider` | `Providers` | +| Views | `views` | `Resources/views` | +| Lang | `lang` | `Resources/lang` | +| Blade components | `component-class` | `View/Components` | + +## DeferrableProvider + +Implementing `DeferrableProvider` means Laravel only resolves this provider when one of its provided bindings is actually needed. `register()` is empty — all work happens in `boot()`. diff --git a/docs/src/pages/system-reference/backend/providers/overview.md b/docs/src/pages/system-reference/backend/providers/overview.md new file mode 100644 index 000000000..0c3364813 --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/overview.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Providers +--- + +# Providers + +Modularous ships with seven service providers. They form a strict registration hierarchy — the application only needs to register one entry point (`ModularityProvider`) and all others are booted in order. + +## Provider Hierarchy + +``` +ModularityProvider ← register in config/app.php +├── GeoIPServiceProvider (third-party) +├── TimezoneServiceProvider (third-party) +├── BaseServiceProvider ← core bindings, macros, view composers +├── ModuleServiceProvider ← per-module boot (migrations, routes, views, lang) +├── RouteServiceProvider ← system & module routing, route macros +├── AuthServiceProvider ← gates, Horizon auth, email verification +└── CoverageServiceProvider ← code coverage analysis service +``` + +`ServiceProvider` is the abstract base all internal providers extend. It is never registered directly. + +## Provider Reference + +| Provider | Source | Responsibility | +|----------|--------|----------------| +| [`ServiceProvider`](./service-provider) | `Providers/ServiceProvider.php` | Abstract base — `$baseKey`, config merge strategy, publishable view paths | +| [`ModularityProvider`](./modularity-provider) | `Providers/ModularityProvider.php` | Entry point — registers all other providers in order | +| [`BaseServiceProvider`](./base-service-provider) | `Providers/BaseServiceProvider.php` | Core bindings, commands, macros, view composers, log channels, scheduler | +| [`ModuleServiceProvider`](./module-service-provider) | `Providers/ModuleServiceProvider.php` | Boots every enabled nwidart module (migrations, views, lang, commands, providers) | +| [`RouteServiceProvider`](./route-service-provider) | `Providers/RouteServiceProvider.php` | Registers system routes, module routes, and route macros | +| [`AuthServiceProvider`](./auth-service-provider) | `Providers/AuthServiceProvider.php` | Gates, Horizon access, custom email-verification URL/mail | +| [`TelescopeServiceProvider`](./telescope-service-provider) | `Providers/TelescopeServiceProvider.php` | Telescope access control and entry filtering | +| [`CoverageServiceProvider`](./coverage-service-provider) | `Providers/CoverageServiceProvider.php` | Code-coverage analysis bindings and commands | + +## Registration + +Add only the entry-point provider to your application: + +```php +// config/app.php +'providers' => [ + // ... + Unusualify\Modularity\Providers\ModularityProvider::class, +], +``` diff --git a/docs/src/pages/system-reference/backend/providers/route-service-provider.md b/docs/src/pages/system-reference/backend/providers/route-service-provider.md new file mode 100644 index 000000000..4cd1fefdc --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/route-service-provider.md @@ -0,0 +1,71 @@ +--- +sidebarPos: 7 +sidebarTitle: RouteServiceProvider +--- + +# RouteServiceProvider + +**Class**: `Unusualify\Modularity\Providers\RouteServiceProvider` +**Source**: `src/Providers/RouteServiceProvider.php` +**Extends**: `Illuminate\Foundation\Support\Providers\RouteServiceProvider` + +Registers all Modularous system routes, iterates enabled modules to register their routes, and defines the route macros used throughout the application. + +## `boot()` + +1. Registers route macros (`bootMacros()`) +2. Registers route middleware aliases (`bootRouteMiddlewares()` → `ModularityRoutes::generateRouteMiddlewares()`) +3. Loads `routes/channels.php` for broadcasting +4. Calls `parent::boot()` which triggers `map()` + +## Route Map + +### System routes (`mapSystemRoutes`) + +| Group | Middleware | File | +|-------|-----------|------| +| Auth routes | `web` + default middlewares | `routes/auth.php` | +| Admin web routes | Domain-scoped (`admin_app_url`) + web panel middlewares | `routes/web.php` | +| Admin API routes | `/api` prefix + web panel middlewares | `routes/api.php` | +| Front routes | `web` | `routes/front.php` | +| Glide image route | — | `GET /{glide.base_path}/{path}` → `GlideController` | + +The Glide route is only registered when `media_library.image_service` is set to the Glide service class. + +### Module routes (`mapModuleRoutes`) + +For each enabled module, six route groups are registered: + +| Group | Scope | Source file | +|-------|-------|-------------| +| Module web (manual) | Module prefix + admin domain | `Routes/web.php` | +| Module front (manual) | `app.url` domain | `Routes/front.php` | +| Module panel (macro) | Admin domain + panel middlewares | `Routes/web.php` via `Route::moduleRoutes()` | +| Module front (macro) | `app.url` + web middlewares | `Routes/front.php` via `Route::moduleFrontRoutes()` | +| Module public API | `ModularityRoutes::getPublicApiGroupOptions()` | `Routes/public-api.php` (if exists) | +| Module auth API | `ModularityRoutes::getAuthApiGroupOptions()` | `Routes/api.php` (if exists) | +| Module API (macro) | `api.` prefix | `Routes/api.php` via `Route::moduleApiRoutes()` | + +## Route Macros + +| Macro | HTTP | Description | +|-------|------|-------------| +| `Route::hasAdmin($name)` | — | Returns the fully-qualified admin route name if it exists, `false` otherwise | +| `Route::host(...$models)` | — | Delegates to `HostRoutingRegistrar::host()` for host-based route binding | +| `Route::moduleRoutes($module)` | — | Registers admin panel routes for a module via `ModularityRoutes::registerModuleRoutes()` | +| `Route::moduleFrontRoutes($module)` | — | Registers front-facing routes for a module | +| `Route::moduleApiRoutes($module)` | — | Registers API routes for a module | +| `Route::additionalRoutes($url, $name, $options)` | Mixed | Registers a standard set of extra routes: `reorder`, `restore`, `bulkRestore`, `forceDelete`, `bulkForceDelete`, `bulkDelete`, `duplicate`, `tags`, `tagsUpdate`, `assignments`, `createAssignment` | +| `Route::apiAdditionalRoutes($url, $name, $options)` | Mixed | Registers API-specific extra routes from `ModularityRoutes::getCustomApiRoutes()` | + +### `additionalRoutes` HTTP verbs + +| Route | Verb | +|-------|------| +| `reorder`, `bulkPublish`, `bulkFeature`, `bulkDelete`, `bulkRestore`, `bulkForceDelete` | POST | +| `publish`, `feature`, `restore`, `forceDelete`, `tagsUpdate` | PUT | +| `duplicate`, `preview` | PUT `/{id}` | +| `browser`, `tags` | GET | +| `restoreRevision` | GET `/{id}` | +| `assignments` | GET `/{id}/assignments` | +| `createAssignment` | POST `/{id}/assignments` | diff --git a/docs/src/pages/system-reference/backend/providers/service-provider.md b/docs/src/pages/system-reference/backend/providers/service-provider.md new file mode 100644 index 000000000..f41c4fc40 --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/service-provider.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 8 +sidebarTitle: ServiceProvider +--- + +# ServiceProvider + +**Class**: `Unusualify\Modularity\Providers\ServiceProvider` +**Source**: `src/Providers/ServiceProvider.php` +**Extends**: `Illuminate\Support\ServiceProvider` + +Abstract base class that all Modularous service providers extend. Sets the `$baseKey` shared across all providers and overrides two Laravel methods with Modularous-aware implementations. + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$baseName` | `string` | Human-readable package name, sourced from `MODULARITY_BASE_NAME` env var (default: `'Modularity'`) | +| `$baseKey` | `string` | Snake-case version of `$baseName` used as the config namespace key (e.g. `modularity`) | +| `$terminalNamespace` | `string` | Root namespace for console commands: `Unusualify\Modularity\Console` | +| `$viewSourcePath` | `string` | Absolute path to `resources/views` inside the package | + +## Constructor + +```php +public function __construct($app) +``` + +Reads `MODULARITY_BASE_NAME` from the environment and derives `$baseKey` via `Str::snake()`. Both values are available to every extending provider. + +## Overridden Methods + +### `mergeConfigFrom(string $path, string $key)` + +Replaces Laravel's default `array_merge` with `array_merge_recursive_preserve()`. This ensures that deeply nested config keys set by the application are **not** overwritten by the package defaults — application config always wins at every level of nesting. + +### `getPublishableViewPaths(): array` + +Scans every path in `config('view.paths')` for a `modules/{baseKey}` subdirectory. Returns only the paths that exist, allowing the application to publish and override package views while the package views serve as fallback. + +## Usage + +Never register `ServiceProvider` directly. Extend it when creating a new internal provider: + +```php +class MyProvider extends \Unusualify\Modularity\Providers\ServiceProvider +{ + public function boot(): void + { + // $this->baseKey is available here + $this->loadViewsFrom( + array_merge( + $this->getPublishableViewPaths(), + [__DIR__ . '/../../resources/views'] + ), + $this->baseKey + ); + } +} +``` diff --git a/docs/src/pages/system-reference/backend/providers/telescope-service-provider.md b/docs/src/pages/system-reference/backend/providers/telescope-service-provider.md new file mode 100644 index 000000000..971bfbbf7 --- /dev/null +++ b/docs/src/pages/system-reference/backend/providers/telescope-service-provider.md @@ -0,0 +1,74 @@ +--- +sidebarPos: 9 +sidebarTitle: TelescopeServiceProvider +--- + +# TelescopeServiceProvider + +**Class**: `Unusualify\Modularity\Providers\TelescopeServiceProvider` +**Source**: `src/Providers/TelescopeServiceProvider.php` +**Extends**: `Laravel\Telescope\TelescopeApplicationServiceProvider` + +Configures Laravel Telescope access control and entry filtering. Registered automatically by [`BaseServiceProvider`](./base-service-provider) — not listed in `ModularityProvider`'s provider array. + +## `boot()` + +1. Calls `$this->gate()` to define the `viewTelescope` gate +2. Sets the Telescope auth callback: + +```php +Telescope::auth(function ($request) { + return app()->environment('local') || + Gate::check('viewTelescope', [$request->user()]); +}); +``` + +Telescope is accessible to everyone in `local` environments. In other environments, only users who pass the `viewTelescope` gate can access it. + +## `register()` + +### Entry filtering + +```php +Telescope::filter(function (IncomingEntry $entry) use ($isLocal) { + return $isLocal || + $entry->isReportableException() || + $entry->isFailedRequest() || + $entry->isFailedJob() || + $entry->isScheduledTask() || + $entry->isSlowQuery() || + $entry->hasMonitoredTag(); +}); +``` + +In `local`, every entry is recorded. In production/staging, only the following entry types are stored: + +| Entry type | Method | +|------------|--------| +| Reportable exceptions | `isReportableException()` | +| Failed HTTP requests | `isFailedRequest()` | +| Failed queued jobs | `isFailedJob()` | +| Scheduled task runs | `isScheduledTask()` | +| Slow database queries | `isSlowQuery()` | +| Entries with a monitored tag | `hasMonitoredTag()` | + +### Sensitive data filtering (non-local) + +Removes the following from recorded entries: + +| Type | Filtered values | +|------|----------------| +| Request parameters | `_token` | +| Request headers | `cookie`, `x-csrf-token`, `x-xsrf-token` | + +## `gate()` + +Defines the `viewTelescope` gate. By default the allowed-emails list is empty — add emails to grant non-local access: + +```php +Gate::define('viewTelescope', function ($user) { + return in_array($user->email, [ + 'admin@example.com', + ]); +}); +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/content.md b/docs/src/pages/system-reference/backend/repository-traits/content.md new file mode 100644 index 000000000..c75bb5e49 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/content.md @@ -0,0 +1,231 @@ +--- +sidebarPos: 2 +sidebarTitle: Content Traits +--- + +# Content Repository Traits + +These traits handle slug persistence, JSON spread attributes, tagging, and multi-locale translations at the repository level. They pair with the corresponding Entity Traits (`HasSlug`, `HasSpreadable`, `IsTranslatable`, `HasTranslation`). + +--- + +## SlugsTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\SlugsTrait` + +Persists locale-aware URL slugs after save, removes them on delete, restores them on restore, and provides slug-based model lookup methods. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `afterSaveSlugsTrait` | For each locale, creates or updates the slug record via `$object->updateOrNewSlug()`. Respects the model's `$slugAttributes` property and derives the `active` flag from translation state. | +| `afterDeleteSlugsTrait` | Soft-deletes all associated slug records | +| `afterRestoreSlugsTrait` | Restores all soft-deleted slug records | +| `getFormFieldsSlugsTrait` | Populates `fields['translations']['slug'][$locale]` from the model's slug records. Picks the active slug per locale (falls back to the only available slug). | + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `existsSlug` | `(string $slug, array $with, array $withCount, array $scopes): ?Model` | Looks up a published, visible model by slug. Falls back to inactive slugs (sets `$item->redirect = true`) and fallback locale slugs. | +| `existsSlugPreview` | `(string $slug, array $with, array $withCount): ?Model` | Looks up a model by inactive slug only — used for preview/draft URLs | +| `getSlugParameters` | `($object, $fields, $slug): array` | Merges the model's `slugAttributes` into the slug array for compound slug generation | + +### Slug Resolution Order + +`existsSlug()` follows this lookup chain: + +1. Active slug in the current locale (published + visible scopes applied) +2. Inactive slug in the current locale → `redirect = true` +3. Active slug in the fallback locale (if `translatable.use_property_fallback` is enabled) → `redirect = true` + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\SlugsTrait; + +class PageRepository extends Repository +{ + use SlugsTrait; +} + +// Resolve a page by slug (returns null or sets redirect flag) +$page = $repo->existsSlug('about-us', ['medias'], [], ['section' => 'main']); + +// Preview an unpublished slug +$draft = $repo->existsSlugPreview('upcoming-feature'); +``` + +--- + +## SpreadableTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\SpreadableTrait` + +Moves form fields marked as `spreadable` into and out of the model's JSON `Spread` morph record. This trait bridges the gap between flat form fields and the `HasSpreadable` entity trait's JSON storage. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsSpreadableTrait` | Registers inputs matching `type: *spread` | +| `prepareFieldsBeforeSaveSpreadableTrait` | Moves spreadable fields from the flat `$fields` array into `$fields[$spreadableSavingKey]` and removes them from the top level | +| `beforeSaveSpreadableTrait` | Creates the `Spread` record if missing, merges spreadable field values into the JSON content, then sets the spread attribute on the model | +| `getFormFieldsSpreadableTrait` | Reads the `Spread` content and excludes spreadable input keys (those are surfaced as top-level fields by the entity trait's `__get`) | + +### Data Flow + +``` +Form Submit + ├─ prepareFieldsBeforeSave: flat fields → grouped into spread_payload + └─ beforeSave: spread_payload merged into Spread JSON record + +Form Load (getFormFields) + └─ Spread JSON → spread_payload field (minus spreadable input keys) + └─ Entity trait __get surfaces individual keys as model attributes +``` + +### Helper Method + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getSpreadableInputKeys` | `(array $schema): array` | Filters schema inputs where `spreadable === true` and returns their names | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\SpreadableTrait; + +class ProductRepository extends Repository +{ + use SpreadableTrait; +} + +// Form schema with spreadable fields: +// ['type' => 'text', 'name' => 'meta_title', 'spreadable' => true] +// ['type' => 'textarea', 'name' => 'meta_description', 'spreadable' => true] +// +// These are stored in the Spread JSON record, not as database columns. +``` + +--- + +## TagsTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\TagsTrait` + +Handles tag synchronization (create, remove, bulk), locale-aware tags, tag querying, and data table filtering by tag. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsTagsTrait` | Registers inputs matching `type: tagger` | +| `afterSaveTagsTrait` | Syncs tags on the model. Supports translated (per-locale) tags via `setLocaleTags()`, flat tags via `setTags()`, and bulk tagging via `tag()`/`untag()`. | +| `getFormFieldsTagsTrait` | Loads tags from the model, grouped by locale for translated inputs or as a flat name list otherwise | +| `filterTagsTrait` | Adds a relation filter scope for `tag_id` → `tags` relationship | + +### After-Save Logic + +The trait distinguishes two modes: + +**Standard save** — when `bulk_tags` is absent: +- If `translated` → calls `$object->setLocaleTags($value, $locale)` per locale. +- Otherwise → calls `$object->setTags($fields['tags'])`. + +**Bulk save** — when `bulk_tags` is present (used in multi-select table operations): +- Computes the difference between `previous_common_tags` and new `bulk_tags`. +- Calls `$object->untag($removed)` then `$object->tag($newTags)`. + +### Query Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getTags` | `(string $query, array $ids, bool $translated, ?callable $map): Collection` | Fetches tags ordered by usage count. Optionally filters by slug search, taggable IDs, locale grouping, and a custom map callback. | +| `getTagsList` | `(): Collection` | Returns `[{label, value}]` pairs for select dropdowns — only tags with `count > 0` | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\TagsTrait; + +class ArticleRepository extends Repository +{ + use TagsTrait; +} + +// Fetch all tags for dropdown +$tags = $repo->getTagsList(); + +// Search tags matching "tech" +$results = $repo->getTags('tech'); + +// Get locale-grouped tags +$grouped = $repo->getTags('', [], true); +``` + +--- + +## TranslationsTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\TranslationsTrait` + +Handles the full translation lifecycle: preparing per-locale fields before save, hydrating translated fields for form editing, filtering/searching across translations, and ordering by translated columns. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsTranslationsTrait` | Registers inputs where `translated === true` | +| `prepareFieldsBeforeCreateTranslationsTrait` | Delegates to `prepareFieldsBeforeSave` for new records | +| `prepareFieldsBeforeSaveTranslationsTrait` | Restructures flat translated fields into per-locale arrays with `active` flags based on `translationLanguages` | +| `getFormFieldsTranslationsTrait` | Loads `$object->translations` and maps each translated attribute back into `fields['translations'][$attribute][$locale]` | +| `filterTranslationsTrait` | Adds `orWhereHas('translations', ...)` for search terms that match translatable attributes | +| `orderTranslationsTrait` | Joins the translations table and orders by the translated column for the current locale | + +### Field Preparation Flow + +``` +Input: fields['title']['en'] = 'Hello', fields['title']['fr'] = 'Bonjour' + fields['translationLanguages'] = [{value: 'en', published: true}, ...] + +Output: fields['en'] = {active: true, title: 'Hello'} + fields['fr'] = {active: false, title: 'Bonjour'} +``` + +The `active` flag is derived from: +1. The `translationLanguages` published state. +2. If no language is published, the first locale is auto-published. + +### Search Behavior + +When scopes contain `searches` with field names matching translated attributes, the trait adds an `orWhereHas('translations', ...)` clause with `LIKE` matching, then removes those attributes from the main scopes to prevent duplicate filtering. + +### Ordering Behavior + +When an order column is a translated attribute: +1. Joins the translations table. +2. Filters by the current locale. +3. Orders by the translated column. +4. Selects only the main table columns to avoid ambiguity. + +### Published Scopes + +| Method | Returns | Description | +|--------|---------|-------------| +| `getPublishedScopesTranslationsTrait` | `['withActiveTranslations']` | Used by slug/front-end resolution to only show records with active translations | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\TranslationsTrait; + +class ArticleRepository extends Repository +{ + use TranslationsTrait; +} + +// Translated fields are automatically restructured before save +// and hydrated back into form fields on edit. +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/CacheableTrait.md b/docs/src/pages/system-reference/backend/repository-traits/logic/CacheableTrait.md new file mode 100644 index 000000000..92844a665 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/CacheableTrait.md @@ -0,0 +1,86 @@ +--- +sidebarPos: 2 +sidebarTitle: CacheableTrait +--- + +# CacheableTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\CacheableTrait` + +Adds relationship-aware caching to repository queries. Composes the `Cacheable` and `HasUserAwareCache` traits to provide TTL-based caching with automatic tagging by related model IDs for granular invalidation. + +## Composed Traits + +```php +trait CacheableTrait +{ + use Cacheable, HasUserAwareCache; +} +``` + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$trackCacheRelations` | `bool` | `true` | Whether to extract foreign key IDs from results and tag caches with them | + +## Configuration Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `withRelationTracking` | `(bool $enabled = true): static` | Enable or disable relationship tracking (fluent) | +| `withoutRelationTracking` | `(): static` | Shorthand to disable relationship tracking | + +## Core Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `cacheableCount` | `(string $slug, callable $callback, array $additionalParams): int` | Generic cached count with consistent key generation. Used for count-based filters (all, published, draft, etc.) | +| `getByIdCached` | `($id, $with, $withCount, $lazy, $scopes, $useDefaultScopes): Model` | Cached version of `getById()`. Supports user-aware cache keys when scopes are present. Falls back to uncached `getById()` when caching is disabled. | + +## Relationship Extraction + +| Method | Signature | Description | +|--------|-----------|-------------| +| `extractRelationIds` | `(Model $model): array` | Scans model attributes for `*_id` foreign keys, resolves them to related model classes via relationship methods, returns `['ModelClass' => id]` | +| `extractRelationIdsFromCollection` | `(Collection $collection): array` | Aggregates `extractRelationIds()` across a collection, returns `['ModelClass' => [id1, id2, ...]]` | + +## Cache-with-Relations Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `rememberIndexWithRelations` | `(string $cacheKey, int $ttl, ...)` | Fetches index data, extracts relation IDs from all items (handles both paginated and collection results), caches with relation tags via `ModularityCache::putWithRelations()` | +| `rememberRecordWithRelations` | `(string $cacheKey, int $ttl, ...)` | Fetches a single record, extracts relation IDs, caches with relation tags | + +## Cache Key Strategy + +For `getByIdCached()`: + +- **Simple queries** (no extra with/scopes) → `generateTypeCacheKey('record', ['id' => $id])` +- **Complex queries** → `ModularityCache::generateCacheKey(module, route, 'record', $params)` +- **User-aware queries** (scopes present + user-aware cache enabled) → adds user context to params + +## How Relation Tracking Works + +``` +1. Query executes (cache miss) +2. extractRelationIds() scans result for *_id attributes + └─ e.g., company_id=5 → ['App\Models\Company' => 5] +3. ModularityCache::putWithRelations(key, data, ttl, module, route, relations) +4. When Company #5 is updated → cache entries tagged with it are invalidated +``` + +## Usage + +```php +// Relationship tracking is enabled by default on all repositories. + +// Fetch a cached record +$item = $repo->getByIdCached($id, ['images', 'tags']); + +// Temporarily disable relation tracking for performance +$items = $repo->withoutRelationTracking()->getByIdCached($id); + +// Use cached counts +$count = $repo->cacheableCount('published', fn() => $this->model->published()->count()); +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/CollationSelector.md b/docs/src/pages/system-reference/backend/repository-traits/logic/CollationSelector.md new file mode 100644 index 000000000..d049e8f0d --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/CollationSelector.md @@ -0,0 +1,53 @@ +--- +sidebarPos: 3 +sidebarTitle: CollationSelector +--- + +# CollationSelector + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\CollationSelector` + +Applies explicit MySQL collation to `LIKE` search queries on text columns, solving case/accent sensitivity mismatches that arise when the database collation differs from the connection default. Composes `CompilesJsonPaths`. + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$shouldUseSearchCollation` | `bool` | `false` | Per-repository override to force collation even when the global setting is off | +| `$collationSelectorColumns` | `array` | `char`, `varchar`, `tinytext`, `text`, `mediumtext`, `longtext`, `enum`, `set` | Column types that receive explicit collation | + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setShouldUseSearchCollation` | `(bool $value): static` | Fluent setter for the per-repository collation override | +| `shouldUseSearchCollation` | `($query): bool` | Returns `true` when either `Modularity::shouldUseCollationForSearch()` or `$shouldUseSearchCollation` is set **and** the connection driver is MySQL | +| `isCollationQuery` | `($query): bool` | Returns `true` only for MySQL connections | +| `addSearchCollationToQuery` | `(Builder $query, string $field, mixed $value, ?Model $model): Builder` | Adds a collation-aware `LIKE` clause. Handles JSON paths (casts to `CHAR`), checks column types from `getColumnTypes()`, and falls back to a standard `orWhere LIKE` for non-text columns | +| `getCollationSelectorColumns` | `(): array` | Returns the list of column types that receive explicit collation | + +## How Collation Is Applied + +``` +1. isCollationQuery() → only applies on MySQL +2. shouldUseSearchCollation() → global flag or per-repo override +3. field contains '->' → JSON path: CAST(field AS CHAR) COLLATE {collation} LIKE ? +4. field type in $collationSelectorColumns + → field COLLATE {collation} LIKE ? +5. otherwise → orWhere($field, LIKE, '%value%') +``` + +The collation string is read from the connection config (`collation` key) and defaults to `utf8mb4_unicode_ci`. + +## Usage + +```php +// Enable globally via config/modularity.php: +'search' => ['use_collation' => true] + +// Enable for a specific repository only: +$repo->setShouldUseSearchCollation(true); + +// CollationSelector is used internally inside searchIn() / searchInRelationships() +// — no direct call is needed in most cases. +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/CountBuilders.md b/docs/src/pages/system-reference/backend/repository-traits/logic/CountBuilders.md new file mode 100644 index 000000000..ea5b887c6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/CountBuilders.md @@ -0,0 +1,40 @@ +--- +sidebarPos: 4 +sidebarTitle: CountBuilders +--- + +# CountBuilders + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\CountBuilders` + +Provides cached aggregate count queries for the standard record status tabs (all, published, draft, trash) and a generic status-by-method helper. Composes `MethodTransformers`. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getCountForAll` | `(): int` | Counts all records (with current `$countScope` filters applied) | +| `getCountForPublished` | `(): int` | Counts records matching the `published()` scope | +| `getCountForDraft` | `(): int` | Counts records matching the `draft()` scope | +| `getCountForTrash` | `(): int` | Counts soft-deleted records (`onlyTrashed()`) | +| `getCountFor` | `(string $method, array $args): int` | Counts records for any named Eloquent scope (`scope{Method}` must exist on the model). Throws if the scope is not found. | + +All methods use `cacheableCount()` (from `CacheableTrait`) for consistent cache key generation and TTL management. The active `$countScope` (set by `getCountByStatusSlug()`) is included in the cache key. + +## Model Interface + +For more efficient count queries, the model may define `newCountQuery()` — a query builder that excludes heavy joins or eager loads present in the standard `newQuery()`. If absent, `newQuery()` is used. + +## Usage + +```php +// Standard status tab counts +$all = $repo->getCountForAll(); +$published = $repo->getCountForPublished(); +$draft = $repo->getCountForDraft(); +$trashed = $repo->getCountForTrash(); + +// Custom scope count +$active = $repo->getCountFor('active'); +$premium = $repo->getCountFor('byPlan', ['premium']); +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/Dates.md b/docs/src/pages/system-reference/backend/repository-traits/logic/Dates.md new file mode 100644 index 000000000..d2dc5aadb --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/Dates.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 5 +sidebarTitle: Dates +--- + +# Dates + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\Dates` + +Normalises date fields to `Y-m-d H:i:s` format before they are saved to the database. Works with Carbon for robust parsing of whatever date string the frontend sends. + +## Lifecycle Hooks + +| Hook | Signature | Description | +|------|-----------|-------------| +| `prepareFieldsBeforeCreateDates` | `($fields): array` | Delegates to `prepareFieldsBeforeSaveDates(null, $fields)` | +| `prepareFieldsBeforeSaveDates` | `($object, $fields): array` | Iterates `$model->getDates()` and normalises any matching field present in `$fields`. Empty values are set to `null`. | + +## Normalisation + +``` +Input: fields['published_at'] = '2024-06-15T14:30:00.000Z' (ISO 8601 from JS) +Output: fields['published_at'] = '2024-06-15 14:30:00' (MySQL format) + +Input: fields['expires_at'] = '' +Output: fields['expires_at'] = null +``` + +If `Carbon::parse()` throws (unparseable value), the field is set to `null` rather than propagating the exception. + +## Usage + +Dates normalisation is automatic for any column returned by the model's `getDates()` method. No additional configuration is required — add date columns to your model's `$casts` or `$dates` array: + +```php +class Post extends Model +{ + protected $casts = [ + 'published_at' => 'datetime', + 'expires_at' => 'datetime', + ]; +} +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/DispatchEvents.md b/docs/src/pages/system-reference/backend/repository-traits/logic/DispatchEvents.md new file mode 100644 index 000000000..19c61b2f6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/DispatchEvents.md @@ -0,0 +1,53 @@ +--- +sidebarPos: 6 +sidebarTitle: DispatchEvents +--- + +# DispatchEvents + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\DispatchEvents` + +Dispatches domain events after CUD operations, deferred until after the current database transaction commits. Events are fired by `SystemNotification` module event classes. + +## Events Map + +| Action | Event Class | +|--------|-------------| +| `create` / `store` | `ModelCreated` | +| `edit` / `update` | `ModelUpdated` | +| `delete` / `destroy` | `ModelDeleted` | +| `forceDelete` | `ModelForceDeleted` | +| `restore` | `ModelRestored` | + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `dispatchEvent` | `(Model $model, string $action): bool` | Looks up the action in `$events`, serialises the model for delete/force-delete events, then schedules `commitEvent()`. Returns `false` if the action has no registered event. | +| `commitEvent` | `(string $event, Model $model, ?array $serializedData): void` | Wraps `$event::dispatch(...)` in `DB::afterCommit()` — the event only fires if the transaction succeeds. | + +## Why After Commit? + +Dispatching after commit prevents event listeners from acting on records that may be rolled back. Listeners that update caches or send notifications will only run when the data is guaranteed to exist. + +## Delete Serialisation + +For `delete`, `destroy`, and `forceDelete` actions, `$model->toArray()` is captured **before** the commit callback so listeners receive the full record data even after it has been soft/hard deleted. + +## Usage + +```php +// Called automatically by Repository::create(), update(), delete(), etc. +// To add a custom event, override $events in your repository: + +class PostRepository extends Repository +{ + protected $events = [ + ...parent::$events, + 'publish' => PostPublished::class, + ]; +} + +// Trigger from a custom action: +$repo->dispatchEvent($post, 'publish'); +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/InspectTraits.md b/docs/src/pages/system-reference/backend/repository-traits/logic/InspectTraits.md new file mode 100644 index 000000000..9282a1861 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/InspectTraits.md @@ -0,0 +1,51 @@ +--- +sidebarPos: 7 +sidebarTitle: InspectTraits +--- + +# InspectTraits + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\InspectTraits` + +Runtime trait introspection for the repository and its model. Used internally by the base `Repository` to discover which lifecycle hooks are available. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `hasBehavior` | `(string $behavior): bool` | Checks if the repository uses `Repositories\Traits\{Behavior}Trait`. For translation traits, also verifies that the model is translatable. | +| `isTranslatable` | `(string $column): bool` | Checks if a specific column is translatable on the model | +| `isSoftDeletable` | `(): bool` | Checks if the model supports soft deletes | +| `hasModelTrait` | `(string $trait): bool` | Checks if the model class uses a specific trait (fully qualified name) | + +## Behavior Check Logic + +```php +$repo->hasBehavior('translations'); +// 1. Checks: classHasTrait($this, TranslationsTrait::class) → true/false +// 2. If behavior starts with 'translation': +// also checks $this->model->isTranslatable() → must be true +``` + +## Usage + +```php +// Internally used by the Repository base class: +if ($this->hasBehavior('slugs')) { + $this->afterSaveSlugsTrait($object, $fields); +} + +// Check model capabilities +if ($repo->isTranslatable('title')) { + // handle translated field +} + +if ($repo->isSoftDeletable()) { + // handle soft delete UI +} + +// Check for a specific model trait +if ($repo->hasModelTrait('Unusualify\Modularity\Entities\Traits\HasStateable')) { + // show state filters +} +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/MethodTransformers.md b/docs/src/pages/system-reference/backend/repository-traits/logic/MethodTransformers.md new file mode 100644 index 000000000..355e8395d --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/MethodTransformers.md @@ -0,0 +1,85 @@ +--- +sidebarPos: 8 +sidebarTitle: MethodTransformers +--- + +# MethodTransformers + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\MethodTransformers` + +The lifecycle-hook dispatcher at the heart of every `Repository`. Composes `ManageTraits` (which enumerates hook methods from loaded traits via naming conventions) and `CacheableTrait`. All repository traits plug into the lifecycle through this class. + +## Lifecycle Methods + +Each method fans out to every `{hookName}{TraitName}` method discovered by `traitsMethods()`: + +| Method | Signature | When called | +|--------|-----------|-------------| +| `prepareFieldsBeforeCreate` | `($fields): array` | Before `model->create()` | +| `prepareFieldsBeforeSave` | `($object, $fields): array` | Before any `$object->save()` — receives and must return modified `$fields` | +| `beforeSave` | `($object, $fields): void` | Just before `$object->save()` | +| `afterSave` | `($object, $fields): void` | Immediately after save — side effects (file moves, pivot syncs, etc.) | +| `afterUpdateBasic` | `($object, $fields): void` | After a basic update (non-form-save path) | +| `afterDelete` | `($object): void` | After soft-delete | +| `afterForceDelete` | `($object): void` | After hard-delete | +| `afterRestore` | `($object): void` | After restore from trash | +| `hydrate` | `($object, $fields): Model` | Sets in-memory relationships on the model before save | +| `getFormFields` | `($object, $schema, $noSerialization): array` | Builds the full form-field payload for the edit form — calls `setColumns()` first | +| `getShowFields` | `($object, $schema): array` | Builds the field payload for a show/detail view | +| `filter` | `($query, $scopes): Builder` | Applies all filter hooks from traits, then processes remaining scopes (scoped methods, LIKE, whereIn, exact-match) | +| `order` | `($query, $orders): Builder` | Applies all order hooks from traits, then applies explicit column→direction orders | +| `getTableFilters` | `($scope): array` | Aggregates filter tab definitions from all traits | +| `getFormActions` | `($scope): array` | Aggregates form action button definitions from all traits | +| `appendFormSchema` | `($scope): array` | Aggregates schema additions to append to the form | +| `prependFormSchema` | `($scope): array` | Aggregates schema additions to prepend to the form | + +## Column Management + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setColumns` | `($inputs): void` | Calls every `setColumns{Trait}` hook, merges results into `$this->traitColumns` | +| `getColumns` | `(?string $trait): array` | Returns the registered column names for a specific trait (used inside trait hooks to know which inputs they own) | +| `traitHasInput` | `(string $traitClass, string $inputName): bool` | Checks if a given trait has registered a specific input name | +| `anyTraitHasInput` | `(array $traitClasses, string $inputName): bool` | Returns `true` if any of the given traits owns the input name | + +## Field Cleanup + +`cleanupFields($object, $fields)` runs automatically before every `prepareFieldsBeforeCreate` / `prepareFieldsBeforeSave`: + +- **Checkboxes** (`$model->checkboxes`): missing → `false`, present → cast to bool. +- **Nullable** (`$model->nullable`): missing fields → `null`. + +## Scope Filtering (in `filter()`) + +The `filter()` method processes scopes in three passes: + +1. **Trait hooks** — each `filter{Trait}($query, $scopes)` runs first, modifying the query in-place. +2. **Relation scopes** (`addRelation{Relation}`) — resolved via reflection; handles `MorphTo`, `HasOneThrough`, `HasOne`, and `BelongsTo` automatically. +3. **Remaining scopes** — dispatched as: + - Eloquent scope method if `$model->hasScope($column)` + - `LIKE` if column starts with `%` + - Negation (`!value`) → `<>` + - Array value → `whereIn` + - Scalar value → `where` + +## Status Counts + +`getCountByStatusSlug($slug, $scope)` delegates to trait-level `getCountByStatusSlug{Trait}` hooks, falling back to the built-in slugs: `all`, `published`, `draft`, `trash`. + +## Usage + +```php +// The lifecycle methods are called by Repository internally. +// Override in your repository class to customise behaviour: + +class PostRepository extends Repository +{ + use TranslationsTrait, FilesTrait; + + public function prepareFieldsBeforeSaveMyHook($object, $fields): array + { + $fields['slug'] = Str::slug($fields['title']); + return $fields; + } +} +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/QueryBuilder.md b/docs/src/pages/system-reference/backend/repository-traits/logic/QueryBuilder.md new file mode 100644 index 000000000..b07060b2a --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/QueryBuilder.md @@ -0,0 +1,85 @@ +--- +sidebarPos: 9 +sidebarTitle: QueryBuilder +--- + +# QueryBuilder + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\QueryBuilder` + +The primary data-retrieval layer used by every `Repository`. Provides paginated listing, single-record lookup, multi-ID fetching, column-value filtering, and a flexible flat-list helper. Composes `MethodTransformers` (for caching and filter delegation) and `SerializeModel` (for cache serialisation). + +## Core Retrieval Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `get` | `($with, $scopes, $orders, $perPage, $appends, $forcePagination, $id, $exceptIds)` | Runs the paginated query. Handles full-text search (translated + relationship fields), custom scopes, ordering, `id`-positioned pages, and post-load appends. Returns a `LengthAwarePaginator` or `Collection`. | +| `getPaginator` | `(same signature as get)` | Main entry point — transparently delegates to `getCached()` when caching is enabled for the `index` type; otherwise calls `get()` directly. | +| `paginate` | `(?Request $request): LengthAwarePaginator` | Convenience wrapper that reads `itemsPerPage`, `scopes`, `orders`, `eager`, `appends`, `exceptIds`, and `id` from the current HTTP request and calls `getPaginator()`. | +| `getCached` | `(same signature as get)` | Serialises the paginator result to an array (including items cast via `serializeModel()`), caches it, then reconstructs a `LengthAwarePaginator` on hit. Avoids serialising Laravel's paginator closure directly. | +| `getById` | `($id, $with, $withCount, $lazy, $scopes, $useDefaultScopes): Model` | Fetches a single record by primary key. Supports eager loading (`with`), count selects (`withCount`), lazy loading chains (`lazy`), optional scope filtering, and transparently includes soft-deleted records. Throws `ModelNotFoundException` if absent. | +| `getByIds` | `(array $ids, $appends, $with, $scopes, $orders, ...): Collection` | Fetches multiple records by IDs. Supports the same eager/lazy options as `getById` plus post-load append resolution. | +| `getByColumnValue` | `($column, $value, $with, $scopes, $orders, $isFormatted, $schema): Collection` | Fetches records by a single column value (scalar → `where`, array → `whereIn`). | +| `listAll` | `($with, $scopes, $orders): Collection` | Returns all records (unpaginated) with optional relations, scopes, and ordering. | +| `list` | `($column, $with, $scopes, $orders, $appends, $perPage, $exceptId, $forcePagination)` | Lightweight select-list helper. Resolves translatable columns, adds required foreign keys for eager-loaded `BelongsTo`/`MorphTo` relations, and avoids selecting absent columns. Supports optional pagination. | +| `formatWiths` | `($query, array $with): array` | Normalises the `$with` array — passes plain strings through unchanged, and converts associative `['functions' => [...]]` entries into closures applied in sequence. | + +## Pagination Modes + +`perPage` controls how `get()` returns results: + +| Value | Behaviour | +|-------|-----------| +| `> 0` | Standard `paginate($perPage)` — returns a `LengthAwarePaginator` | +| `-1` | Fetches all rows, wraps them in a `LengthAwarePaginator` with `perPage = total` | +| `0` | Returns an empty `LengthAwarePaginator` (total is still counted from the query) | + +## ID-Positioned Paging + +When `$id` is provided to `get()` or `getPaginator()`, the paginator automatically jumps to the page containing that record: + +``` +1. Clone the filtered/ordered query +2. pluck('id') → build ordered ID list +3. array_search($id, $orderedIds) → position +4. page = floor(position / perPage) + 1 +``` + +## Search Handling + +The `scopes` array may include: + +| Key | Type | Purpose | +|-----|------|---------| +| `search` | `string` | The search term | +| `searches` | `array` | Column names to search in | + +Fields containing `.` in the `searches` array are treated as relationship fields and routed through `searchInRelationships()`. Fields matching `translatedAttributes` are handled by `TranslationsTrait::filterTranslationsTrait()`. Remaining fields are passed to `searchIn()` on the main table. + +## Deprecated Methods + +| Method | Replacement | +|--------|-------------| +| `getByIdWithScopes()` | `getById($id, ..., useDefaultScopes: true)` — removed in v1.0.0 | +| `$isFormatted` in `getByIds` | Removed in v1.0.0 — passed as `null` to suppress deprecation warning | + +## Usage + +```php +// Standard paginated listing from a controller +$items = $repo->getPaginator( + with: ['images'], + scopes: ['search' => 'Laravel', 'searches' => ['title', 'body']], + orders: ['created_at' => 'desc'], + perPage: 15, +); + +// Request-driven pagination (API endpoints) +$items = $repo->paginate(request()); + +// Fetch single record with relations +$item = $repo->getById($id, with: ['tags', 'category'], withCount: ['comments']); + +// Lightweight list for a select/autocomplete input +$options = $repo->list('title', with: ['category'], perPage: -1); +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/RelationshipHelpers.md b/docs/src/pages/system-reference/backend/repository-traits/logic/RelationshipHelpers.md new file mode 100644 index 000000000..e1f02b1eb --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/RelationshipHelpers.md @@ -0,0 +1,40 @@ +--- +sidebarPos: 10 +sidebarTitle: RelationshipHelpers +--- + +# RelationshipHelpers + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\RelationshipHelpers` + +Utility methods for discovering relationships on a model and resolving foreign key names. Used internally by `Relationships` and `MethodTransformers`. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getDefinedRelations` | `($relations = null): array` | Uses PHP reflection to find all public, zero-parameter model methods whose return type matches `Illuminate\Database\Eloquent\Relations\{...}`. Pass a string or array to filter by specific relation type(s). | +| `definedRelations` | `($relations = null): array` | Delegates to `$model->definedRelations()` if the model defines it, otherwise calls `getDefinedRelations()`. | +| `getRelationForeignKey` | `($relation): string` | Dispatches to the appropriate foreign-key resolver based on relation type (`BelongsTo`, `BelongsToMany`, `HasMany`). Throws `InvalidArgumentException` for unsupported types. | + +## Foreign Key Resolvers (private) + +| Method | Relation Type | Returns | +|--------|---------------|---------| +| `getForeignKeyBelongsTo` | `BelongsTo` | `getForeignKeyName()` — the column on the owning model | +| `getForeignKeyBelongsToMany` | `BelongsToMany` | `getRelatedPivotKeyName()` — the related ID column on the pivot | +| `getForeignKeyHasMany` | `HasMany` | `getForeignKeyName()` — the column on the child model | + +## Usage + +```php +// Discover all BelongsToMany relationships on the model +$pivotRelations = $repo->definedRelations('BelongsToMany'); + +// Discover multiple types at once +$syncable = $repo->definedRelations(['BelongsToMany', 'MorphToMany']); + +// Resolve the foreign key for a runtime relation instance +$fk = $repo->getRelationForeignKey($post->categories()); +// → 'category_id' (pivot's related key) +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/Relationships.md b/docs/src/pages/system-reference/backend/repository-traits/logic/Relationships.md new file mode 100644 index 000000000..568577e7c --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/Relationships.md @@ -0,0 +1,60 @@ +--- +sidebarPos: 11 +sidebarTitle: Relationships +--- + +# Relationships + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\Relationships` + +Handles automatic synchronisation of all Eloquent relationship types when a record is saved. Composes `CheckSnapshot` and `ResolveConnector` to support special snapshot-sourced `HasMany` children and connector-resolved repositories. + +## Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `afterSaveRelationships` | Main sync handler — iterates all relationship types and syncs them from `$fields` | +| `prepareFieldsBeforeSaveRelationships` | Pre-processes `HasMany` fields: if the related model uses `HasSnapshot`, numeric IDs are wrapped into the expected snapshot source format | +| `getFormFieldsRelationships` | Loads all relationship values back into `$fields` for form editing | +| `afterForceDeleteRelationships` | Detaches all `BelongsToMany` relations when a record is hard-deleted | + +## Relationship Type Handling in `afterSaveRelationships` + +| Type | Sync Method | Notes | +|------|-------------|-------| +| `MorphToMany` | `$object->{relation}()->sync($ids)` | Tags are excluded (`'tags'` is handled by `TagsTrait`) | +| `MorphTo` | Sets `_type` / `_id` columns directly on the model | Iterates configured types and matches from `$fields` | +| `BelongsToMany` | `$object->{relation}()->sync($payload)` | Payload may be a Collection, plain array, or pivot-keyed array | +| `HasMany` | Create / Update / Delete via the related `Repository` | Existing IDs not in `$fields` are deleted via `bulkDelete()` | +| `MorphMany` | Create / Update / Delete directly on the related model | Existing IDs not in `$fields` are deleted via `whereIn(...)->delete()` | + +Whenever a sync produces changes (`attached`, `updated`, or `detached` > 0), `TouchableEloquentModel::letEloquentModelBeTouched(true)` is called to update the parent's timestamps. + +## Relation Discovery + +The trait provides helpers used by `MethodTransformers`: + +| Method | Returns | Description | +|--------|---------|-------------| +| `getBelongsToManyRelations` | `array` | All `BelongsToMany` methods on the model | +| `getHasManyRelations` | `array` | All `HasMany` methods on the model | +| `getMorphManyRelations` | `array` | All `MorphMany` methods on the model | +| `getMorphToManyRelations` | `array` | All `MorphToMany` methods on the model | +| `getMorphToRelations` | `array` | Parsed from `morphTo` input types in `inputs()` schema — returns `['morphToName' => [{name, model}, ...]]` | + +## Usage + +```php +// Relationships are synced automatically during Repository::update() / create(). +// To define which relationships are managed, declare them on the model: + +class Post extends Model +{ + public function tags(): MorphToMany { ... } + public function categories(): BelongsToMany { ... } + public function comments(): HasMany { ... } +} + +// The form schema must include input names matching the relationship method names: +// ['type' => 'select', 'name' => 'categories', 'multiple' => true, ...] +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/Schema.md b/docs/src/pages/system-reference/backend/repository-traits/logic/Schema.md new file mode 100644 index 000000000..4a6247260 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/Schema.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 12 +sidebarTitle: Schema +--- + +# Schema + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\Schema` + +Manages the active input schema for a repository operation and provides helpers to chunk inputs into flat arrays. Composes `ManageTraits`. + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$schema` | `array\|null` | The overriding schema set for the current operation. When `null`, `inputs()` is used. | + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `setSchema` | `(?array $schema): void` | Overrides the active schema for the duration of the current operation (e.g., a specific form context) | +| `getSchema` | `(): array\|null` | Returns the currently active overriding schema | +| `getInputs` | `(): array` | Returns `$schema` if set, otherwise falls back to `$this->inputs()` | +| `getRawInputs` | `(): array` | Always returns `$this->inputs()` — ignores any `$schema` override | +| `getRawChunkedInputs` | `(bool $all, bool $noGroupChunk): array` | Chunks `getRawInputs()` via `chunkInputs()` | +| `getChunkedInputs` | `(bool $all, bool $noGroupChunk): array` | Chunks `getInputs()` (respects `$schema` override) via `chunkInputs()` | + +## Chunking Behaviour + +`chunkInputs()` flattens the inputs array into a single-level associative array keyed by input `name`. The `$all` flag includes inputs that are normally hidden (e.g. conditional inputs). The `$noGroupChunk` flag prevents group-level chunking, returning all inputs regardless of group boundaries. + +## Usage + +```php +// Temporarily override schema for a specific operation +$repo->setSchema($customSchema); +$fields = $repo->getFormFields($object); +$repo->setSchema(null); // restore + +// Get all chunked inputs for column detection +$inputs = $repo->getRawChunkedInputs(all: true); +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/TouchableEloquentModel.md b/docs/src/pages/system-reference/backend/repository-traits/logic/TouchableEloquentModel.md new file mode 100644 index 000000000..e180bd9fc --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/TouchableEloquentModel.md @@ -0,0 +1,44 @@ +--- +sidebarPos: 13 +sidebarTitle: TouchableEloquentModel +--- + +# TouchableEloquentModel + +**Namespace**: `Unusualify\Modularity\Repositories\Logic\TouchableEloquentModel` + +Deferred timestamp touching for parent models. Tracks whether a relationship sync produced changes and calls `$object->touch()` at the end of the save cycle rather than after each individual sync. + +## Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$mustTouchEloquentModel` | `bool` | `false` | Accumulates the "should touch" signal across the entire save operation | + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `mustTouchEloquentModel` | `(): void` | Sets `$mustTouchEloquentModel = true` | +| `letEloquentModelBeTouched` | `(bool $value): void` | Only upgrades the flag to `true` — never resets it from `true` to `false`. Called by `Relationships::afterSaveRelationships()` after each sync operation that produced changes. | +| `touchEloquentModel` | `(Model $object): Model` | Calls `$object->touch()` when `$mustTouchEloquentModel` is `true` **or** when `$object->mustTouchable === true`. Returns the model. | + +## Why Deferred Touching? + +Multiple relationship syncs run in a single save cycle (BelongsToMany, HasMany, MorphMany, etc.). Without deferred touching, each changed sync would individually call `touch()`, resulting in multiple `UPDATE` queries and unnecessary cache invalidations. By accumulating the flag and calling `touch()` once at the end, the parent record's `updated_at` is updated exactly once. + +## Usage + +```php +// Touching is handled automatically by Relationships::afterSaveRelationships(). +// To force a touch regardless of relationship changes: + +$repo->mustTouchEloquentModel(); +$repo->touchEloquentModel($object); + +// To respect a model-level always-touch flag, set on the model: +class Post extends Model +{ + public bool $mustTouchable = true; +} +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/logic/overview.md b/docs/src/pages/system-reference/backend/repository-traits/logic/overview.md new file mode 100644 index 000000000..0c52868f7 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/logic/overview.md @@ -0,0 +1,26 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Logic Traits +--- + +# Logic Repository Traits + +These traits live in the `Repositories\Logic` namespace and are composed directly into the base `Repository` class. They are **not** opt-in — every repository gets them automatically. Together they provide the full infrastructure layer: query building, relationship hydration, lifecycle hooks, date normalisation, event dispatch, collation, caching, and timestamp management. + +## Trait Reference + +| Trait | Purpose | +|-------|---------| +| [QueryBuilder](./QueryBuilder) | Paginated listing, single-record lookup, multi-ID fetching, and flat-list helpers | +| [MethodTransformers](./MethodTransformers) | Lifecycle hook dispatcher — fans out to every `{hookName}{Trait}` method across loaded traits | +| [Relationships](./Relationships) | Syncs all Eloquent relationship types (BelongsToMany, HasMany, MorphMany, MorphTo, MorphToMany) on save | +| [RelationshipHelpers](./RelationshipHelpers) | Reflection-based relationship discovery and foreign key resolution | +| [Schema](./Schema) | Active schema management and input chunking helpers | +| [CountBuilders](./CountBuilders) | Cached aggregate counts for status tabs (all, published, draft, trash) | +| [Dates](./Dates) | Normalises date fields to `Y-m-d H:i:s` before save | +| [DispatchEvents](./DispatchEvents) | Dispatches domain events (create/update/delete/restore) after database commit | +| [CollationSelector](./CollationSelector) | Applies explicit MySQL collation to LIKE search queries on text columns | +| [CacheableTrait](./CacheableTrait) | Relationship-aware caching for index and record queries | +| [InspectTraits](./InspectTraits) | Runtime introspection — checks whether repository or model uses a given trait | +| [TouchableEloquentModel](./TouchableEloquentModel) | Deferred `updated_at` touching — fires exactly once after all relationship syncs | diff --git a/docs/src/pages/system-reference/backend/repository-traits/media.md b/docs/src/pages/system-reference/backend/repository-traits/media.md new file mode 100644 index 000000000..793666912 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/media.md @@ -0,0 +1,180 @@ +--- +sidebarPos: 3 +sidebarTitle: Media Traits +--- + +# Media Repository Traits + +These traits handle persistence of file and image attachments from forms into pivot tables. They pair with the corresponding [Entity Traits](../entity-traits/media/overview) (`HasFiles`, `HasImages`, `HasFileponds`) which define the Eloquent relationships. + +--- + +## FilepondsTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\FilepondsTrait` + +Persists Filepond temporary uploads to permanent storage via the `Filepond` facade after a model is saved. Supports nested repeater files, locale-separated uploads, and associative (translated) file arrays. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsFilepondsTrait` | Scans form inputs for `type: filepond` and registers their names | +| `afterSaveFilepondsTrait` | Iterates registered columns and delegates to `Filepond::saveFile()` | +| `getFormFieldsFilepondsTrait` | Loads existing Filepond records grouped by role/locale into form fields | + +### Column Detection + +Inputs whose `type` matches the pattern `/filepond/` are automatically claimed by this trait. + +### After-Save Flow + +``` +afterSaveFilepondsTrait($object, $fields) + └─ for each filepond column: + ├─ nested repeater pattern (*.*)? → iterate indices, save per-index + ├─ associative (locale keys)? → save per-locale via Filepond::saveFile() + └─ flat array? → save directly via Filepond::saveFile() +``` + +### Form Field Hydration + +When editing an existing record, the trait loads `$object->fileponds` grouped by `role`, then: + +- **Translated inputs** — groups by locale and maps each to `mediableFormat()`. +- **Non-translated inputs** — uses the default locale (or first available) and maps to `mediableFormat()`. +- **Missing roles** — initializes with empty arrays (locale-keyed if translated). + +### Usage + +```php +// In your repository — just use the trait +use Unusualify\Modularity\Repositories\Traits\FilepondsTrait; + +class ArticleRepository extends Repository +{ + use FilepondsTrait; +} + +// Form inputs detected automatically: +// ['type' => 'filepond', 'name' => 'documents'] +// ['type' => 'filepond', 'name' => 'gallery', 'translated' => true] +``` + +--- + +## FilesTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\FilesTrait` + +Syncs `File` model attachments through the `fileables` pivot table. Handles locale-aware file assignment, pivot record creation/update, and in-memory hydration for preview. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsFilesTrait` | Registers inputs matching `type: file` (exact word boundary) | +| `hydrateFilesTrait` | Sets the `files` relation in-memory without persisting — used for preview/validation | +| `afterSaveFilesTrait` | Attaches new files or updates existing pivot records via `$object->files()` | +| `getFormFieldsFilesTrait` | Loads existing files grouped by role/locale into form field arrays | + +### Column Detection + +Inputs matching the pattern `/\bfile\b/` (word boundary) are registered. + +### Hydration (Preview) + +```php +hydrateFilesTrait($object, $fields) +``` + +Builds a `Collection` of `File` models with in-memory pivots (`role`, `locale`, `file_id`) and sets it as the `files` relation. This lets downstream code (presenters, serializers) access files before the save is committed. + +### After-Save Flow + +For each file in the resolved file list: +- If the file has an existing pivot `id` → `updateExistingPivot()`. +- Otherwise → `attach()` with role and locale metadata. + +### Form Field Hydration + +Groups `$object->files` by `pivot.role`, then: + +- **Translated inputs** — further groups by `pivot.locale` and maps each to `mediableFormat()`. +- **Non-translated inputs** — selects the default locale (fallback to `app.fallback_locale`) and maps to `mediableFormat()`. +- **Missing roles** — initializes empty `Collection` (locale-keyed if translated). + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\FilesTrait; + +class DocumentRepository extends Repository +{ + use FilesTrait; +} + +// Detected from form schema: +// ['type' => 'file', 'name' => 'attachment'] +// ['type' => 'file', 'name' => 'contract', 'translated' => true] +``` + +--- + +## ImagesTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\ImagesTrait` + +Syncs `Media` model attachments through the `mediables` pivot table. Structurally similar to `FilesTrait` but handles image-specific metadata (crop settings, image metadatas). + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsImagesTrait` | Registers inputs matching `type: image` | +| `hydrateImagesTrait` | Sets the `medias` relation in-memory for preview | +| `afterSaveImagesTrait` | Attaches or updates media pivot records | +| `getFormFieldsImagesTrait` | Loads existing media grouped by role/locale into form fields | + +### Column Detection + +Inputs matching the pattern `/image/` are registered. + +### After-Save Flow + +For each media item in the resolved media list: +- If the media has an existing pivot `id` → `updateExistingPivot()`. +- Otherwise → `attach()` with `media_id`, `role`, `metadatas`, `crop`, and `locale`. + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `pushImage` | `($object, $images, $imagesData, $role, $locale, $index): Collection` | Appends image pivot data to the collection, resolving existing pivot IDs | +| `getCrops` | `(string $role): array` | Returns crop configuration from `$model->mediasParams[$role]` | + +### Form Field Hydration + +Same grouping strategy as `FilesTrait`: + +- **Translated** — grouped by `pivot.locale`, each mapped to `mediableFormat()`. +- **Non-translated** — default/fallback locale, mapped to `mediableFormat()`. +- **Missing roles** — empty `Collection` (locale-keyed if translated). + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\ImagesTrait; + +class ProductRepository extends Repository +{ + use ImagesTrait; +} + +// Detected from form schema: +// ['type' => 'image', 'name' => 'cover'] +// ['type' => 'image', 'name' => 'gallery', 'translated' => true] + +// Get crop config +$repo->getCrops('cover'); // ['default' => [...], 'thumbnail' => [...]] +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/oauth.md b/docs/src/pages/system-reference/backend/repository-traits/oauth.md new file mode 100644 index 000000000..1c17d37cf --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/oauth.md @@ -0,0 +1,87 @@ +--- +sidebarPos: 4 +sidebarTitle: OAuth Traits +--- + +# OAuth Repository Traits + +This trait provides OAuth user management at the repository level. It pairs with the [`Auth\HasOauth`](../entity-traits/auth/overview) entity trait. + +--- + +## OauthTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\OauthTrait` + +Handles user lookup, provider linking verification, provider token updates, and new user creation from OAuth provider data (e.g., Google, GitHub). Designed to work with Laravel Socialite's `User` contract. + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `oauthUser` | `(SocialiteUser $oauthUser): ?User` | Finds an existing user by email from the OAuth provider response | +| `oauthIsUserLinked` | `(SocialiteUser $oauthUser, string $provider): bool` | Checks whether the user already has a linked record for the given provider and OAuth ID | +| `oauthUpdateProvider` | `(SocialiteUser $oauthUser, string $provider): User` | Updates the token and avatar for an existing provider link, returns the user | +| `oauthCreateUser` | `(SocialiteUser $oauthUser): User` | Creates a new user (or finds by email) with the OAuth profile data and the configured default role | + +### OAuth Login Flow + +``` +1. oauthUser($oauthUser) → Find user by email +2. if user exists: + ├─ oauthIsUserLinked(...) → Check if provider is linked + │ ├─ linked: oauthUpdateProvider(...) → Update token/avatar + │ └─ not linked: link the provider + └─ return user +3. if user doesn't exist: + └─ oauthCreateUser(...) → Create user + link provider +``` + +### User Creation Details + +`oauthCreateUser()` creates a user with: + +| Field | Source | +|-------|--------| +| `email` | `$oauthUser->email` | +| `name` | `$oauthUser->user['given_name']` (falls back to `$oauthUser->name`) | +| `surname` | `$oauthUser->user['family_name']` (falls back to empty string) | +| `role` | `modularityConfig('oauth.default_role')` | +| `published` | `true` | + +The method uses `firstOrNew` to avoid duplicate email records — if a user with the same email already exists, it returns the existing user instead of creating a new one. + +### Configuration + +The default role for newly created OAuth users is configured in: + +```php +// config/modularity.php (or equivalent) +'oauth' => [ + 'default_role' => 'member', +], +``` + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\OauthTrait; + +class UserRepository extends Repository +{ + use OauthTrait; +} + +// In an OAuth callback controller: +$oauthUser = Socialite::driver('google')->user(); + +$existingUser = $repo->oauthUser($oauthUser); + +if ($existingUser) { + if ($repo->oauthIsUserLinked($oauthUser, 'google')) { + $repo->oauthUpdateProvider($oauthUser, 'google'); + } +} else { + $user = $repo->oauthCreateUser($oauthUser); +} +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/overview.md b/docs/src/pages/system-reference/backend/repository-traits/overview.md new file mode 100644 index 000000000..16c24c1df --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/overview.md @@ -0,0 +1,109 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Repository Logic Traits + +Repository traits extend the base `Repository` class with domain-specific persistence logic. While [Entity Traits](../entity-traits/overview) define model-level behavior (relationships, scopes, accessors), repository traits handle **how data flows in and out** — form field hydration, after-save side effects, column detection, table filters, and caching. + +Every repository trait follows a **naming convention** that the base `Repository` automatically discovers and invokes at the correct lifecycle stage: + +| Convention | Signature | When Called | +|------------|-----------|-------------| +| `setColumns{Trait}` | `($columns, $inputs): array` | During boot — registers which form input names this trait manages | +| `prepareFieldsBeforeCreate{Trait}` | `($fields): array` | Before a new record is inserted | +| `prepareFieldsBeforeSave{Trait}` | `($object, $fields): array` | Before any save (create or update) | +| `beforeSave{Trait}` | `($object, $fields): void` | Just before the Eloquent `save()` call | +| `afterSave{Trait}` | `($object, $fields): void` | Immediately after `save()` — side effects (pivot syncing, file moves, etc.) | +| `hydrate{Trait}` | `($object, $fields): Model` | Sets in-memory relationships before save (preview/validation) | +| `getFormFields{Trait}` | `($object, $fields, $schema): array` | Populates form fields when editing an existing record | +| `afterDelete{Trait}` | `($object): void` | Cleanup after soft-delete | +| `afterRestore{Trait}` | `($object): void` | Restoration hook | +| `filter{Trait}` | `($query, &$scopes): void` | Applies default query scopes/filters on index listings | +| `order{Trait}` | `($query, &$orders): void` | Applies ordering logic | +| `getTableFilters{Trait}` | `($scope): array` | Returns filter tab definitions for the data table UI | +| `getFormActions{Trait}` | `($scope): array` | Returns form action button definitions | + +## Trait Groups + +| Group | Namespace | Purpose | +|-------|-----------|---------| +| [Media](#media-traits) | `Repositories\Traits\` | Persist files, images, and Filepond uploads | +| [Relationships](#relationship-traits) | `Repositories\Traits\` | Save assignment, authorization, and creator data | +| [Content](#content-traits) | `Repositories\Traits\` | Slugs, translations, tags, and spreadable JSON | +| [State](#state-traits) | `Repositories\Traits\` | State machine filter lists and table filters | +| [Payment](#payment-traits) | `Repositories\Traits\` | Price and payment record management | +| [Processes](#process-traits) | `Repositories\Traits\` | Workflow processes and repeater blocks | +| [OAuth](#oauth-traits) | `Repositories\Traits\` | Social login user lookup and creation | +| [Logic](#logic-traits) | `Repositories\Logic\` | Caching layer and trait introspection | + +--- + +## Media Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `FilepondsTrait` | [Media →](./media) | Persists Filepond temporary uploads to permanent storage after save | +| `FilesTrait` | [Media →](./media) | Syncs `File` model attachments through the `fileables` pivot | +| `ImagesTrait` | [Media →](./media) | Syncs `Media` model attachments through the `mediables` pivot | + +## Relationship Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `AssignmentTrait` | [Relationships →](./relationships) | Provides assignment form fields, default filters, and table filter tabs | +| `AuthorizableTrait` | [Relationships →](./relationships) | Hydrates authorization record fields and adds authorized/unauthorized filters | +| `CreatorTrait` | [Relationships →](./relationships) | Applies creator-based access scope and prepends creator form input | + +## Content Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `SlugsTrait` | [Content →](./content) | Persists locale-aware slugs and resolves slug-based lookups | +| `SpreadableTrait` | [Content →](./content) | Moves spreadable fields into/from the JSON `Spread` record | +| `TagsTrait` | [Content →](./content) | Syncs tags (with locale support) and provides tag query helpers | +| `TranslationsTrait` | [Content →](./content) | Prepares per-locale translation fields and handles translatable search/ordering | + +## State Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `StateableTrait` | [State →](./state) | Builds state filter lists and table filter tabs from the model's state machine | + +## Payment Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `PricesTrait` | [Payment →](./payment) | Creates, updates, and deletes morphed `Price` records with currency exchange | +| `PaymentTrait` | [Payment →](./payment) | Orchestrates payment price calculation, payment service integration, and pay action | + +## Process Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `ProcessableTrait` | [Processes →](./processes) | Auto-creates workflow processes and hydrates process form fields | +| `RepeatersTrait` | [Processes →](./processes) | Persists nested repeater JSON blocks with locale and media support | + +## OAuth Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `OauthTrait` | [OAuth →](./oauth) | Looks up, links, and creates users from OAuth provider data | + +## Logic Traits + +| Trait | Page | Summary | +|-------|------|---------| +| `QueryBuilder` | [Logic/QueryBuilder →](./logic/QueryBuilder) | Paginated listing, single-record lookup, multi-ID fetching, and flat-list helpers | +| `MethodTransformers` | [Logic/MethodTransformers →](./logic/MethodTransformers) | Lifecycle hook dispatcher — fans out to every `{hookName}{Trait}` method across loaded traits | +| `Relationships` | [Logic/Relationships →](./logic/Relationships) | Syncs all Eloquent relationship types (BelongsToMany, HasMany, MorphMany, MorphTo, MorphToMany) on save | +| `RelationshipHelpers` | [Logic/RelationshipHelpers →](./logic/RelationshipHelpers) | Reflection-based relationship discovery and foreign key resolution | +| `Schema` | [Logic/Schema →](./logic/Schema) | Active schema management and input chunking helpers | +| `CountBuilders` | [Logic/CountBuilders →](./logic/CountBuilders) | Cached aggregate counts for status tabs (all, published, draft, trash) | +| `Dates` | [Logic/Dates →](./logic/Dates) | Normalises date fields to `Y-m-d H:i:s` before save | +| `DispatchEvents` | [Logic/DispatchEvents →](./logic/DispatchEvents) | Dispatches domain events (create/update/delete/restore) after database commit | +| `CollationSelector` | [Logic/CollationSelector →](./logic/CollationSelector) | Applies explicit MySQL collation to LIKE search queries on text columns | +| `CacheableTrait` | [Logic/CacheableTrait →](./logic/CacheableTrait) | Relationship-aware caching for index and record queries | +| `InspectTraits` | [Logic/InspectTraits →](./logic/InspectTraits) | Runtime introspection — checks whether repository or model uses a given trait | +| `TouchableEloquentModel` | [Logic/TouchableEloquentModel →](./logic/TouchableEloquentModel) | Deferred `updated_at` touching — fires exactly once after all relationship syncs | diff --git a/docs/src/pages/system-reference/backend/repository-traits/payment.md b/docs/src/pages/system-reference/backend/repository-traits/payment.md new file mode 100644 index 000000000..300d74b55 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/payment.md @@ -0,0 +1,169 @@ +--- +sidebarPos: 5 +sidebarTitle: Payment Traits +--- + +# Payment Repository Traits + +These traits manage price record persistence and payment workflow orchestration. `PricesTrait` handles basic price CRUD through morph relations, while `PaymentTrait` builds on top of it to calculate totals, integrate payment services, and render the payment action modal. + +--- + +## PricesTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\PricesTrait` + +Creates, updates, and deletes morphed `Price` records for the model. Supports automatic currency exchange conversion when the currency exchange service is active. + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$formatableColumns` | `array` | Price columns included in form field output: `id`, `raw_amount`, `currency_id`, `vat_rate_id`, `price_type_id`, `discount_percentage` | + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsPricesTrait` | Registers inputs matching `type: price` | +| `afterSavePricesTrait` | Creates or updates `Price` records per role. When currency exchange is active, auto-converts to all enabled currencies. Deletes orphaned prices when exchange is not active. | +| `getFormFieldsPricesTrait` | Loads prices by role, maps them to formatable column arrays. Returns default price structure for roles without existing prices. | + +### After-Save Flow + +For each price column (excluding `payment`): + +1. Fetches existing prices for the role. +2. For each submitted price: + - **Existing** (`id` present) → updates the price record. + - **New** → creates a new price with default attributes merged. +3. If currency exchange is active: + - Converts the base amount to each enabled currency via `CurrencyExchange::convertTo()`. + - Creates or updates the converted price record per currency. +4. If currency exchange is **not** active: + - Deletes prices whose IDs are no longer in the submitted data. + +### Form Field Hydration + +Prices are loaded filtered by the user's session currency (when exchange is active) and grouped by role. Each price is mapped to a subset of columns, with amount fields cast to `float`. + +When no price exists for a role, a default structure is returned: + +```php +[ + 'price_value' => 0.00, + 'raw_amount' => 0.00, + 'currency_id' => $sessionCurrencyId, + // ... default attributes from Price model +] +``` + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\PricesTrait; + +class ProductRepository extends Repository +{ + use PricesTrait; +} + +// Price inputs detected from schema: +// ['type' => 'price', 'name' => 'base_price'] +// ['type' => 'price', 'name' => 'wholesale_price'] +``` + +--- + +## PaymentTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\PaymentTrait` + +Orchestrates the full payment lifecycle: calculating totals from related priceable models, creating payment records via payment services, and rendering a payment modal action on forms. Internally uses `PricesTrait`. + +### Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `$paymentTraitRelationName` | `mixed` | `null` | Override the relation name used for payment price | +| `$paymentTraitDefaultCurrencyId` | `int` | `1` | Fallback currency ID when none is set | +| `$requiredTrait` | `string` | `HasPriceable` | Entity trait required on related models for price aggregation | +| `$snapshotTrait` | `string` | `HasSnapshot` | Snapshot trait checked for source-based pricing | + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `afterSavePaymentTrait` | Handles three scenarios: direct payment price update, auto-calculation from related models, and payment service integration | +| `getFormFieldsPaymentTrait` | Populates `fields['payment']` from the model's payment relation | + +### After-Save Logic + +The trait handles three distinct cases in order: + +**1. Direct payment price update** — when `fields['payment_price']` is submitted: +- If the existing price is unpaid → updates it directly. +- If the existing price is paid → creates a replica with new values (preserves payment history). + +**2. Auto-calculation from relations** — when no payment price exists or `force_payment_update` is set: +- Iterates the model's `$paymentRelations`. +- For each related model with `HasPriceable` (or `HasSnapshot` → source with `HasPriceable`), sums `originalBasePrice->raw_amount`. +- Creates or updates the payment price with the total. + +**3. Payment service integration** — when `fields['payment_service_id']` is submitted: +- Loads the payment service configuration. +- Updates VAT rate and discount percentage on the payment price if provided. +- For transferrable services, calls `$paymentPrice->updateOrNewPayment()` with email, creator, receipts, description, status, and currency data. + +### Form Actions + +`getFormActionsPaymentTrait()` returns a modal-based "Pay" button: + +```php +[ + 'paymentTrait' => [ + 'type' => 'modal', + 'label' => 'Pay', + 'icon' => 'mdi-credit-card-outline', + 'color' => 'success', + 'endpoint' => route('admin.system.system_payment.pay'), + 'schema' => [...], // hidden price_id + payment-service input + 'conditions' => [ + ['payment.status', 'not in', ['completed', 'provision', 'refunded']] + ], + 'hideOnCondition' => true, + ], +] +``` + +The button is hidden when payment status is `COMPLETED`, `PROVISION`, or `REFUNDED`. + +### Customization Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getFormActionsConditionsForPayment` | `(): array` | Override on the model to add extra conditions for the Pay button visibility | +| `getFormActionPropsForPaymentTrait` | `(): array` | Override on the model to merge extra props into the Pay action | +| `defaultPaymentPriceFields` | `(): array` | Returns default field values for new payment prices. Resolves `price_type_id`, `vat_rate_id`, and `currency_id` by name/slug/iso. | +| `getDefaultPaymentPriceFields` | `(): array` | Override in your repository to provide custom defaults | +| `getPaymentFormSchema` | `(): array` | Returns the form schema for the payment modal | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\PaymentTrait; + +class OrderRepository extends Repository +{ + use PaymentTrait; + + public function getDefaultPaymentPriceFields(): array + { + return [ + 'price_type_id' => 'one-time', + 'vat_rate_id' => 'standard', + 'currency_id' => 'USD', + ]; + } +} +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/processes.md b/docs/src/pages/system-reference/backend/repository-traits/processes.md new file mode 100644 index 000000000..58f8016ab --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/processes.md @@ -0,0 +1,144 @@ +--- +sidebarPos: 6 +sidebarTitle: Process & Repeater Traits +--- + +# Process & Repeater Repository Traits + +These traits handle workflow process management and nested repeater block persistence. `ProcessableTrait` auto-creates approval processes, while `RepeatersTrait` manages JSON repeater blocks with locale and media support. + +--- + +## ProcessableTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\ProcessableTrait` + +Auto-creates a workflow `Process` record for models that use the `Processable` entity trait. Hydrates the process ID and any nested process schema fields into form fields. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsProcessableTrait` | Registers inputs matching `type: process` | +| `getFormFieldsProcessableTrait` | Sets each process column to the model's process ID (auto-creating the process if needed). If the process input has a nested `schema`, hydrates those sub-fields as well. | + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getProcessId` | `(Model $object, string $status = 'preparing'): mixed` | Returns the existing process ID, or creates a new `Process` with the given status and returns its ID | + +### Form Field Hydration Flow + +``` +getFormFieldsProcessableTrait($object, $fields, $schema) + └─ for each process column: + ├─ $fields[$column] = getProcessId($object) // ensures Process exists + └─ if input has nested schema: + ├─ collect schema inputs not already in $fields + └─ call getFormFields() recursively for those inputs +``` + +This recursive hydration allows process inputs to contain embedded sub-forms (e.g., approval notes, status fields) that are hydrated alongside the process ID. + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\ProcessableTrait; + +class SubmissionRepository extends Repository +{ + use ProcessableTrait; +} + +// Form input detected from schema: +// ['type' => 'process', 'name' => 'approval_process', 'schema' => [...]] + +// Process is auto-created on first form load +$processId = $repo->getProcessId($submission); +``` + +--- + +## RepeatersTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\RepeatersTrait` + +Persists nested repeater blocks (JSON content stored in a `Repeater` morph-many) with full locale support. Internally composes `FilesTrait`, `ImagesTrait`, and `PricesTrait` to handle media and pricing within repeater rows. + +### Composed Traits + +```php +trait RepeatersTrait +{ + use FilesTrait, ImagesTrait, PricesTrait; + // ... +} +``` + +This composition allows repeater blocks to contain file uploads, image attachments, and price inputs that are automatically detected and persisted by their respective traits. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsRepeatersTrait` | Registers inputs matching `type: json-repeater` or `root: json-repeater` from the raw input schema | +| `afterSaveRepeatersTrait` | Creates or updates `Repeater` records per role and locale | +| `getFormFieldsRepeatersTrait` | Loads repeater content from the database and maps it back into form fields | + +### After-Save Flow + +For each repeater column: + +1. Determines if the content is **translated** (from schema `translated` flag). +2. Detects if the submitted data is **locale-keyed** (associative keys matching system locales). + +**Translated repeaters:** +- Iterates all system locales. +- Creates or updates a `Repeater` record per locale with `{role, content, locale}`. + +**Non-translated repeaters:** +- Uses the fallback locale. +- Creates or updates a single `Repeater` record. + +### Form Field Hydration + +When loading form fields for an existing record: + +1. Groups existing repeaters by locale. +2. For each repeater: + - **Translated** — maps content into `fields[$role][$locale]` using `Arr::dot()` / `Arr::set()`. + - **Non-translated** — maps content into `fields[$role]` directly. +3. When no repeater data exists, initializes with empty arrays (locale-keyed if translated, or the input's `default` value). + +### Helper Method + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getRepeaterInputs` | `(?array $schema): array` | Filters raw inputs to return only those with `type: json-repeater` or `root: json-repeater`, enriched with their `translated` flag | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\RepeatersTrait; + +class ProductRepository extends Repository +{ + use RepeatersTrait; +} + +// Form schema with repeater: +// [ +// 'type' => 'json-repeater', +// 'name' => 'features', +// 'translated' => true, +// 'schema' => [ +// ['type' => 'text', 'name' => 'title'], +// ['type' => 'image', 'name' => 'icon'], +// ['type' => 'price', 'name' => 'addon_price'], +// ] +// ] + +// Get detected repeater inputs +$inputs = $repo->getRepeaterInputs(); +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/relationships.md b/docs/src/pages/system-reference/backend/repository-traits/relationships.md new file mode 100644 index 000000000..f034e3351 --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/relationships.md @@ -0,0 +1,139 @@ +--- +sidebarPos: 7 +sidebarTitle: Relationship Traits +--- + +# Relationship Repository Traits + +These traits handle the repository-side persistence and form hydration for assignment, authorization, and creator tracking features. They pair with the corresponding [Entity Traits](../entity-traits/relationships/overview) (`Assignable`, `HasAuthorizable`, `HasCreator`). + +--- + +## AssignmentTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\AssignmentTrait` + +Manages assignment-related form field hydration, default query filtering, and data table filter tabs. Uses the `Allowable` trait internally for role-based permission checks on filter visibility. + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$hasUserAwareCacheAssignmentTrait` | `bool` | Signals the cache layer that this trait produces user-specific results | + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `setColumnsAssignmentTrait` | Registers inputs matching `type: assignment` | +| `getFormFieldsAssignmentTrait` | Sets each assignment column to the model's primary key | +| `filterAssignmentTrait` | Applies `everAssignedToYourRoleOrHasAuthorization` scope to index queries | + +### Table Filters + +`getTableFiltersAssignmentTrait()` returns filter tabs for the data table: + +| Filter | Slug | Scope | +|--------|------|-------| +| My Assignments | `my-assignments` | `isActiveAssignee` | +| Your Role Assignments | `your-role-assignments` | `isActiveAssigneeForYourRole` | +| Completed Assignments | `completed-assignments` | `completedAssignments` | +| Pending Assignments | `pending-assignments` | `pendingAssignments` | +| Your Completed Assignments | `your-completed-assignments` | `yourCompletedAssignments` | +| Team Completed Assignments | `team-completed-assignments` | `teamCompletedAssignments` | +| Your Pending Assignments | `your-pending-assignments` | `yourPendingAssignments` | +| Team Pending Assignments | `team-pending-assignments` | `teamPendingAssignments` | + +::: info +The "Completed Assignments" and "Pending Assignments" tabs are only visible to users with `superadmin`, `admin`, or `manager` roles (checked via `Allowable`). +::: + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getAssignments` | `(mixed $id): Collection` | Returns all `Assignment` records for a given model ID, ordered by `created_at desc` | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\AssignmentTrait; + +class TaskRepository extends Repository +{ + use AssignmentTrait; +} + +$repo->getAssignments($taskId); +``` + +--- + +## AuthorizableTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\AuthorizableTrait` + +Hydrates authorization record data into form fields and provides authorized/unauthorized filter tabs. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `getFormFieldsAuthorizableTrait` | If the model has an existing authorization record, populates `authorized_id` and `authorized_type` into form fields. Casts `authorized_id` to integer when the authorized model does not use UUIDs. | + +### Table Filters + +`getTableFiltersAuthorizableTrait()` returns: + +| Filter | Slug | Scope | Condition | +|--------|------|-------|-----------| +| Authorized | `authorized` | `hasAnyAuthorization` | Only if model `hasAuthorizationUsage()` | +| Unauthorized | `unauthorized` | `unauthorized` | Only if model `hasAuthorizationUsage()` | +| Your Authorizations | `your-authorizations` | `isAuthorizedToYou` | Always shown | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\AuthorizableTrait; + +class ReportRepository extends Repository +{ + use AuthorizableTrait; +} +``` + +--- + +## CreatorTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\CreatorTrait` + +Applies creator-based access scoping on index queries, hydrates the `custom_creator_id` form field, and prepends a creator input to the form schema. + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$hasUserAwareCacheCreatorTrait` | `bool` | Signals the cache layer that results depend on the current user | + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `filterCreatorTrait` | Adds `hasAccessToCreation` scope to limit results to the user's own records (or authorized records) | +| `getFormFieldsCreatorTrait` | Populates `custom_creator_id` from the model's `creator` relation when the schema defines it | +| `prependFormSchemaCreatorTrait` | Prepends a `type: creator` input to the form schema, displaying the creator info at the top of forms | + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\CreatorTrait; + +class ArticleRepository extends Repository +{ + use CreatorTrait; +} + +// Index queries automatically filter by creator access +// Form fields include the creator display +``` diff --git a/docs/src/pages/system-reference/backend/repository-traits/state.md b/docs/src/pages/system-reference/backend/repository-traits/state.md new file mode 100644 index 000000000..2a831f54e --- /dev/null +++ b/docs/src/pages/system-reference/backend/repository-traits/state.md @@ -0,0 +1,97 @@ +--- +sidebarPos: 8 +sidebarTitle: State Traits +--- + +# State Repository Traits + +This trait provides the repository-level logic for the state machine feature. It pairs with the [`HasStateable`](../entity-traits/model-behavior/overview#hasstateable) entity trait. + +--- + +## StateableTrait + +**Namespace**: `Unusualify\Modularity\Repositories\Traits\StateableTrait` + +Builds state-based filter lists and data table filter tabs by querying the model's configured states. Provides count-by-status methods for the table UI. + +### Lifecycle Hooks + +| Hook | Description | +|------|-------------| +| `getTableFiltersStateableTrait` | Returns an array of filter tab definitions, one per state defined in the model's `$default_states` | + +### Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getStateableFilterList` | `(): array` | Builds a list of `{name, code, slug, number}` for each configured state. Applies repository filters, counts matching records, and excludes states with zero results. | +| `getTableFiltersStateableTrait` | `(mixed $scope): array` | Returns table filter tab definitions with `{name, code, slug, methods, params}` for the data table component. Supports role-based visibility via `$stateableFilterUserRoles`. | +| `getCountByStatusSlugStateableTrait` | `(string $slug, array $scope): int\|false` | Returns the count of records matching a given state code. Returns `false` if the slug is not a valid state code. | +| `getStateableList` | `(string $itemValue = 'name'): array` | Returns `[{id, name}]` pairs for all configured states, ordered by the model's `$default_states` definition order. | + +### Filter List Structure + +`getStateableFilterList()` returns an array where each entry is: + +```php +[ + 'name' => 'Published', // translated state name + 'code' => 'published', // state code + 'slug' => 'isStateablePublished', // scope name for filtering + 'number' => 42, // record count +] +``` + +### Table Filter Structure + +`getTableFiltersStateableTrait()` returns filter definitions: + +```php +[ + 'name' => 'Published', + 'code' => 'published', + 'slug' => 'isStateablePublished', + 'methods' => 'getCountByStatusSlug', + 'params' => ['published', $scope], + // optional: 'allowedRoles' => ['admin', 'manager'] +] +``` + +### Role-Based Filter Visibility + +Define `$stateableFilterUserRoles` as a static property on your repository to restrict which roles can see specific state filters: + +```php +class OrderRepository extends Repository +{ + use StateableTrait; + + protected static $stateableFilterUserRoles = ['admin', 'manager']; +} +``` + +### Usage + +```php +use Unusualify\Modularity\Repositories\Traits\StateableTrait; + +class OrderRepository extends Repository +{ + use StateableTrait; +} + +// Get filter list for state-based navigation +$filters = $repo->getStateableFilterList(); +// [ +// ['name' => 'Draft', 'code' => 'draft', 'slug' => 'isStateableDraft', 'number' => 5], +// ['name' => 'Published', 'code' => 'published', 'slug' => 'isStateablePublished', 'number' => 12], +// ] + +// Get state options for dropdowns +$states = $repo->getStateableList(); +// [['id' => 1, 'name' => 'Draft'], ['id' => 2, 'name' => 'Published']] + +// Count records by state +$count = $repo->getCountByStatusSlugStateableTrait('published'); +``` diff --git a/docs/src/pages/system-reference/backend/scheduled-jobs/chatable-scheduler.md b/docs/src/pages/system-reference/backend/scheduled-jobs/chatable-scheduler.md new file mode 100644 index 000000000..2ad8e3850 --- /dev/null +++ b/docs/src/pages/system-reference/backend/scheduled-jobs/chatable-scheduler.md @@ -0,0 +1,136 @@ +--- +sidebarPos: 2 +sidebarTitle: ChatableScheduler +--- + +# ChatableScheduler + +`Unusualify\Modularity\Schedulers\ChatableScheduler` + +Artisan command that polls every minute for unread chat messages and dispatches `ChatableUnreadNotification` to the relevant parties. It is the heartbeat of the in-app chat notification system. + +## Signature + +``` +modularity:scheduler:chatable +``` + +No options or arguments. + +## What It Does + +On each run the command: + +1. **Discovers all `Chatable` models** via `ModularityFinder::getModelsWithTrait(Chatable::class)` — this returns every Eloquent model in the application that uses the `Chatable` trait. + +2. **For each model class**, queries instances that have an actionable unread message using the `hasNotifiableMessage` scope (in chunks of 100). + +3. **For each matching instance**, calls `$item->handleChatableNotification()` which decides whether to fire a notification and to whom. + +## The `hasNotifiableMessage` Scope + +`Unusualify\Modularity\Entities\Scopes\ChatableScopes::scopeHasNotifiableMessage` + +Finds model instances where the **latest chat message**: +- is **not read** (`is_read = false`) +- has **not yet been notified** (`notified_at IS NULL`) +- was created at least `$minuteOffset` minutes ago (when provided) + +```sql +-- Simplified view of what the scope produces: +SELECT chatable.* +FROM chatable +WHERE EXISTS ( + SELECT 1 FROM chats + WHERE chats.chatable_id = chatable.id + AND EXISTS ( + SELECT 1 FROM chat_messages + WHERE chat_messages.chat_id = chats.id + AND chat_messages.is_read = 0 + AND chat_messages.notified_at IS NULL + AND chat_messages.created_at = ( + SELECT MAX(m2.created_at) FROM chat_messages m2 + WHERE m2.chat_id = chat_messages.chat_id + AND m2.deleted_at IS NULL + ) + ) +) +``` + +## Notification Logic (`handleChatableNotification`) + +`Chatable::handleChatableNotification()` decides **who** gets notified: + +``` +latestChatMessage exists? + AND is_read = false? + AND created_at older than $chatableNotificationInterval minutes? + AND notified_at IS NULL? + │ + ├── dispatch UnreadChatMessage event + │ + ├── resolve chatableCreator (via HasCreator) + ├── resolve chatableAuthorizedUser (via HasAuthorizable, if is_authorized) + │ + └── messageCreator exists? + │ + ├── chatableCreator != messageCreator → notify chatableCreator + └── chatableAuthorizedUser != messageCreator → notify chatableAuthorizedUser +``` + +The notification sent is `ChatableUnreadNotification`. See [System Notifications](/system-reference/backend/notifications/system-notifications#chat). + +## Notification Interval + +The guard `created_at older than $chatableNotificationInterval minutes` prevents spamming notifications for very recent messages. The interval defaults to **60 minutes** and can be overridden per model: + +```php +class Order extends Model +{ + use Chatable; + + // Notify only if the message is older than 30 minutes + protected static int $chatableNotificationInterval = 30; +} +``` + +## Schedule + +Registered as `->everyMinute()` in `BaseServiceProvider`: + +```php +$schedule->command('modularity:scheduler:chatable')->everyMinute(); +``` + +Running every minute ensures the notification is sent as close to the interval boundary as possible without requiring a separate queue worker for polling. + +## Manual Usage + +```bash +php artisan modularity:scheduler:chatable +``` + +Useful for testing the notification pipeline or recovering from a scheduler outage. + +## Error Handling + +The entire `handle()` body is wrapped in a `try/catch`: + +```php +try { + // discovery + chunked processing +} catch (\Throwable $th) { + Log::channel('scheduler')->error('Modularity: Chatable scheduler error', [ + 'error' => $th->getMessage(), + 'trace' => $th->getTraceAsString(), + ]); +} +``` + +A failure in one model's processing does **not** surface to the scheduler — the error is logged and the run completes silently. Check `storage/logs/scheduler.log` if notifications stop firing. + +## Related + +- `Chatable` entity trait — adds chat relationships and `handleChatableNotification` to a model. +- [ChatableUnreadNotification](/system-reference/backend/notifications/system-notifications#chat) — the notification class dispatched per unread chat. +- `ChatableScopes` — the query scopes used to find notifiable models. diff --git a/docs/src/pages/system-reference/backend/scheduled-jobs/fileponds-scheduler.md b/docs/src/pages/system-reference/backend/scheduled-jobs/fileponds-scheduler.md new file mode 100644 index 000000000..c50084c42 --- /dev/null +++ b/docs/src/pages/system-reference/backend/scheduled-jobs/fileponds-scheduler.md @@ -0,0 +1,81 @@ +--- +sidebarPos: 3 +sidebarTitle: FilepondsScheduler +--- + +# FilepondsScheduler + +`Unusualify\Modularity\Schedulers\FilepondsScheduler` + +Artisan command that cleans up abandoned filepond uploads. It runs **daily** via the Modularous scheduler registration and can also be triggered manually at any time. + +## Signature + +``` +modularity:fileponds:scheduler {--days=7} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--days` | `7` | Delete temporary filepond records created more than this many days ago | + +## What It Does + +The command calls two `FilepondManager` methods in sequence: + +### 1. `Filepond::clearTemporaryFiles($days)` + +Removes expired temporary uploads — filepond records in the `temporary_fileonds` table whose `created_at` is older than `$days` days. + +Steps performed: +1. Query `TemporaryFilepond` records where `created_at < now()->subDays($days)` +2. For each record, delete its storage directory (`{tmp_path}/{folder_name}/`) +3. Delete the database records +4. Scan the remaining tmp directories and remove any empty folders not referenced by a `TemporaryFilepond` record + +Returns the deleted collection, whose count is written to the log. + +### 2. `Filepond::clearFolders()` + +Removes orphaned storage folders from the main filepond file path — directories that exist on disk but are not referenced by any `Filepond` UUID in the database and are not the tmp path itself. + +Steps performed: +1. List all directories under `$file_path` +2. Build an exclusion list: the tmp path + all UUIDs from `Filepond::all()` +3. Delete any directory in the diff that still contains files + +## Schedule + +Registered as `->daily()` in `BaseServiceProvider`. The default `--days=7` retention period is baked into the schedule entry: + +```php +$schedule->command('modularity:fileponds:scheduler --days=7')->daily(); +``` + +## Manual Usage + +```bash +# Use the default 7-day retention +php artisan modularity:fileponds:scheduler + +# Extend retention to 30 days +php artisan modularity:fileponds:scheduler --days=30 + +# Aggressive cleanup: delete anything older than 1 day +php artisan modularity:fileponds:scheduler --days=1 +``` + +## Logging + +On completion, writes an info entry to the `scheduler` log channel: + +``` +Modularity: Deleted {N} expired temporary fileponds in last {days} days +``` + +No error handling wraps this command — exceptions propagate to the scheduler and are captured by Laravel's default scheduler error handling. + +## Related + +- `flush:filepond` — the manual flush command that also clears filepond data on demand. +- [FilepondManager](/system-reference/backend/services/uploader/overview) — the service class implementing `clearTemporaryFiles` and `clearFolders`. diff --git a/docs/src/pages/system-reference/backend/scheduled-jobs/overview.md b/docs/src/pages/system-reference/backend/scheduled-jobs/overview.md new file mode 100644 index 000000000..4b9de2f91 --- /dev/null +++ b/docs/src/pages/system-reference/backend/scheduled-jobs/overview.md @@ -0,0 +1,66 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Scheduled Jobs +--- + +# Scheduled Jobs + +Modularous registers its own recurring jobs directly against Laravel's `Schedule` inside `BaseServiceProvider`. No `Console\Kernel.php` is needed in the host application — the jobs run as long as the standard Laravel scheduler is active. + +## Registered Jobs + +| Command | Class | Frequency | Purpose | +|---------|-------|-----------|---------| +| `modularity:fileponds:scheduler` | [FilepondsScheduler](./fileponds-scheduler) | Daily | Delete expired temporary filepond uploads and orphaned storage folders | +| `modularity:scheduler:chatable` | [ChatableScheduler](./chatable-scheduler) | Every minute | Send unread-chat notifications for all `Chatable` models | +| `telescope:prune` | Laravel Telescope | Daily | Prune Telescope entries older than 168 hours (7 days) | + +## How They Are Registered + +The schedule is wired up in `BaseServiceProvider::boot()` using `callAfterResolving` so the bindings are resolved after the container is fully booted: + +```php +$this->callAfterResolving(Schedule::class, function (Schedule $schedule) { + + $schedule->command('modularity:fileponds:scheduler --days=7') + ->daily(); + + $schedule->command('telescope:prune --hours=168') + ->daily() + ->appendOutputTo(storage_path('logs/scheduler.log')); + + $schedule->command('modularity:scheduler:chatable') + ->everyMinute(); +}); +``` + +Both scheduler classes (`ChatableScheduler`, `FilepondsScheduler`) are discovered automatically from `src/Schedulers/*.php` via `CommandDiscovery` and registered as Artisan commands, so they can also be run manually: + +```bash +php artisan modularity:fileponds:scheduler +php artisan modularity:scheduler:chatable +``` + +## Prerequisites + +The host application must have the Laravel scheduler running. Add one cron entry to the server: + +```cron +* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1 +``` + +## Logging + +Both schedulers write to the `scheduler` log channel on error or completion. Configure this channel in `config/logging.php`: + +```php +'scheduler' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/scheduler.log'), + 'level' => 'debug', + 'days' => 14, +], +``` + +Telescope pruning appends its output directly to `storage/logs/scheduler.log` via `->appendOutputTo(...)`. diff --git a/docs/src/pages/system-reference/backend/services/assets.md b/docs/src/pages/system-reference/backend/services/assets.md new file mode 100644 index 000000000..3598ce415 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/assets.md @@ -0,0 +1,55 @@ +--- +sidebarPos: 8 +sidebarTitle: Assets +--- + +# Assets + +**File**: `src/Services/Assets.php` + +Resolves frontend asset URLs for both **production** (compiled manifest lookup) and **local development** (Vite dev server). Used internally by Blade views to load the compiled Vue application scripts and stylesheets. + +## How It Works + +In **production**, `asset()` reads the compiled `unusual-manifest.json` from the public directory and returns the hashed/versioned asset path. + +In **local development** (when `modularity.is_development = true` and the environment is `local` or `development`), it fetches the manifest from the running Vite dev server and returns the dev server URL. + +## Key Methods + +| Method | Description | +|--------|-------------| +| `asset($file)` | Primary entry point. Returns dev server URL in development mode, manifest URL in production. | +| `prodAsset($file)` | Look up the file in the compiled manifest. Falls back to `/{public_dir}/{file}` if not found. | +| `devAsset($file)` | Return the dev server URL for a file. Returns `null` if not in dev mode. | +| `getManifestFilename()` | Resolve the absolute path to the manifest file (checks public dir first, then vendor path). | + +## Configuration Keys + +| Config key | Default | Description | +|------------|---------|-------------| +| `modularity.public_dir` | `'unusual'` | Public directory name under `public/` | +| `modularity.manifest` | `'unusual-manifest.json'` | Manifest filename | +| `modularity.development_url` | `'http://localhost:8080'` | Vite dev server base URL | +| `modularity.is_development` | `false` | Enable dev mode asset resolution | +| `modularity.vendor_path` | — | Path to the package vendor dir (used as fallback for manifest) | + +## Dev Mode Detection + +Dev mode is active when **both** conditions are true: +1. `app()->environment('local', 'development')` is `true` +2. `modularity.is_development` config is `true` + +To enable local development asset serving: + +```php +// config/modularity.php +'is_development' => env('MODULARITY_DEV', false), +'development_url' => env('MODULARITY_DEV_URL', 'http://localhost:8080'), +``` + +```dotenv +# .env +MODULARITY_DEV=true +MODULARITY_DEV_URL=http://localhost:8080 +``` diff --git a/docs/src/pages/system-reference/backend/services/broadcast-manager.md b/docs/src/pages/system-reference/backend/services/broadcast-manager.md new file mode 100644 index 000000000..003234dfc --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/broadcast-manager.md @@ -0,0 +1,95 @@ +--- +sidebarPos: 9 +sidebarTitle: BroadcastManager +--- + +# BroadcastManager + +**File**: `src/Services/BroadcastManager.php` + +Extracts WebSocket broadcasting configuration from Modularous event classes tied to a specific model. The resulting config array is passed to the frontend (via Inertia shared data or the Blade footer) so that **Laravel Echo** can subscribe to the correct channels dynamically, without hardcoding channel names in Vue components. + +## How It Works + +Given a model and a list of event class names, `BroadcastManager` instantiates each event with the model, reads the channels from `broadcastOn()` and the event name from `broadcastAs()`, then groups them into a structured array per channel. + +## Key Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `forModel` *(static)* | `forModel($model, array $eventClasses): array` | Primary entry point. Builds the broadcast config for the model. | +| `getBroadcastConfiguration` | `getBroadcastConfiguration(): array` | Returns the grouped channel + event array. Called internally by `forModel`. | + +## Return Structure + +```php +[ + [ + 'name' => 'private-orders.42', // channel name + 'type' => 'private', // 'private' | 'public' + 'events' => [ + ['event' => 'OrderCreated'], + ['event' => 'OrderUpdated'], + ], + ], + // ... additional channels +] +``` + +## Example + +```php +use Unusualify\Modularity\Services\BroadcastManager; +use Modules\SystemNotification\Events\ModelCreated; +use Modules\SystemNotification\Events\ModelUpdated; + +$broadcastConfig = BroadcastManager::forModel($order, [ + ModelCreated::class, + ModelUpdated::class, +]); + +// Pass to Inertia or Blade: +Inertia::share('broadcastConfig', $broadcastConfig); +``` + +## Event Requirements + +Each event class passed to `forModel` must: + +1. Accept the model as its first constructor argument +2. Implement `broadcastOn()` returning an array of `Channel` or `PrivateChannel` instances +3. Optionally implement `broadcastAs()` returning the event name string (defaults to class basename) + +```php +use Illuminate\Broadcasting\PrivateChannel; +use Unusualify\Modularity\Events\ModelEvent; + +class OrderCreated extends ModelEvent +{ + public function broadcastOn(): array + { + return [new PrivateChannel('orders.' . $this->model->id)]; + } + + public function broadcastAs(): string + { + return 'OrderCreated'; + } +} +``` + +## Frontend Integration + +Once the config is shared with Vue via Inertia, use the `useBroadcast` composable or subscribe directly with Laravel Echo: + +```js +broadcastConfig.forEach(({ name, type, events }) => { + const channel = type === 'private' + ? Echo.private(name) + : Echo.channel(name) + + events.forEach(({ event }) => { + channel.listen(event, (payload) => handleEvent(event, payload)) + }) +}) +``` diff --git a/docs/src/pages/system-reference/backend/services/cache-concerns/cache-helpers.md b/docs/src/pages/system-reference/backend/services/cache-concerns/cache-helpers.md new file mode 100644 index 000000000..bef7709a6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/cache-concerns/cache-helpers.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 2 +sidebarTitle: CacheHelpers +--- + +# CacheHelpers + +**File**: `src/Services/Concerns/CacheHelpers.php` +**Uses**: `CacheTags`, `CacheInvalidation` + +`CacheHelpers` is the primary trait consumed by `ModularityCacheService`. It wraps standard Laravel cache operations (`remember`, `get`, `put`, `forget`, `flush`) with tag awareness — automatically applying the correct tag set when the cache store supports tags, and falling back to untagged operations otherwise. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `remember` | `remember(string $key, int $ttl, Closure $cb, ?string $module, ?string $route)` | Tag-aware `Cache::remember()` | +| `rememberForever` | `rememberForever(string $key, Closure $cb, ?string $module, ?string $route)` | Tag-aware `Cache::rememberForever()` | +| `rememberWithRelations` | `rememberWithRelations(string $key, int $ttl, Closure $cb, ?string $module, ?string $route, array $relations)` | `remember` + adds relation tags for granular per-record invalidation | +| `get` | `get(string $key, $default, ?string $module, ?string $route)` | Tag-aware `Cache::get()` | +| `put` | `put(string $key, $value, int $ttl, ?string $module, ?string $route): bool` | Tag-aware `Cache::put()` | +| `putWithRelations` | `putWithRelations(string $key, $value, int $ttl, ?string $module, ?string $route, array $relations): bool` | `put` + relation tags | +| `has` | `has(string $key, ?string $module, ?string $route): bool` | Tag-aware existence check | +| `forget` | `forget(string $key, ?string $module, ?string $route): bool` | Tag-aware key deletion (uses `onlyRoute: true` tag for narrow scope) | +| `flush` | `flush(): bool` | Flush all modularity caches (by global prefix tag, or pattern fallback) | + +## Tag Selection Logic + +Each method applies tags as follows when the store supports them and `$module` is provided: + +| `$module` | `$route` | Tags used | +|-----------|----------|-----------| +| set | set | `getModuleRouteTags($module, $route)` | +| set | `null` | `getModuleTags($module)` | +| `null` | — | No tags (plain store call) | + +## `isEnabled` Guard + +Every method checks `isEnabled($module, $route)` first. If caching is disabled for the given scope, `remember` / `rememberForever` / `rememberWithRelations` call the callback directly and return its value; `get` returns `$default`; `put` / `putWithRelations` return `false`. + +## Example + +```php +// In a repository +$result = $cacheService->remember( + key: $cacheKey, + ttl: 3600, + callback: fn() => $this->model->with('tags')->paginate(), + moduleName: 'Orders', + moduleRouteName: 'order' +); + +// With relation tags for granular invalidation +$result = $cacheService->rememberWithRelations( + key: $cacheKey, + ttl: 3600, + callback: fn() => Order::with('company')->find($id), + moduleName: 'Orders', + moduleRouteName: 'order', + relations: ['Company' => $order->company_id] +); +``` diff --git a/docs/src/pages/system-reference/backend/services/cache-concerns/cache-invalidation.md b/docs/src/pages/system-reference/backend/services/cache-concerns/cache-invalidation.md new file mode 100644 index 000000000..fe1312dff --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/cache-concerns/cache-invalidation.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 3 +sidebarTitle: CacheInvalidation +--- + +# CacheInvalidation + +**File**: `src/Services/Concerns/CacheInvalidation.php` +**Uses**: `CacheTags`, `ModularModel`, `WarmupCache` + +`CacheInvalidation` provides all cache-clearing operations. Methods range from coarse (flush an entire module) to fine-grained (flush caches tagged with a specific model record). + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `invalidateModule` | `invalidateModule(string $moduleName): bool` | Flush all caches for a module (all routes) | +| `invalidateModuleRoute` | `invalidateModuleRoute(string $module, string $route): bool` | Flush all caches for one route within a module | +| `invalidateByRelatedModel` | `invalidateByRelatedModel(string $modelClass, $id): bool` | Flush only caches tagged with `rel:{Model}:{id}` | +| `invalidateByRelatedModels` | `invalidateByRelatedModels(array $relations): int` | Bulk version; returns count of tags flushed | +| `invalidateByPattern` | `invalidateByPattern(string $pattern): int` | Redis SCAN pattern delete (no-tags fallback only) | +| `invalidateCountCaches` | `invalidateCountCaches(string $module, string $route, bool $onlyRoute): void` | Flush count-type caches for a route | +| `invalidateIndexCaches` | `invalidateIndexCaches(string $module, string $route, bool $onlyRoute): void` | Flush index/listing caches for a route | +| `invalidateFormattedItemCache` | `invalidateFormattedItemCache(string $module, string $route, $id): void` | Flush formatted-item cache for one record | +| `invalidateFormItemCache` | `invalidateFormItemCache(string $module, string $route, $id): void` | Flush form-item cache for one record | +| `invalidateForModel` | `invalidateForModel(Model $model, array $types, array $options): void` | Full model invalidation — flushes all relevant cache types and optionally warms up new caches | + +## `invalidateForModel` Detail + +This is the primary entry point called from model observers. It: + +1. Resolves `$moduleName` and `$moduleRouteName` from the model via `getModuleNameFromModel()` / `getModuleRouteNameFromModel()`. +2. With tags: flushes the route tag entirely. +3. Without tags: selectively invalidates `counts`, `index`, `formattedItem`, `formItem` by pattern, only if each type is enabled and the model is not newly created. +4. Optionally calls `warmupByModel($model)` after invalidation (controlled by `$options['warmup']`, default `true`). + +## Tag vs Pattern Behaviour + +| Store type | Invalidation | +|------------|-------------| +| Redis / Memcached (tags supported) | `Cache::tags([...])->flush()` — fast, atomic | +| File / database (no tags) | `invalidateByPattern()` via Redis SCAN — only works when the cache driver is Redis without tags | + +> `invalidateByPattern()` logs a warning if called while tags are enabled, because tagged cache keys have a hashed namespace prefix that makes pattern matching impossible. + +## Example + +```php +// After saving an Order model +app(ModularityCacheService::class)->invalidateForModel($order); + +// Selectively — only invalidate index and count caches +app(ModularityCacheService::class)->invalidateForModel($order, [ + 'index' => true, + 'counts' => true, + 'formattedItem' => false, + 'formItem' => false, +], ['warmup' => false]); + +// Granular — flush only caches that referenced Company:5 +app(ModularityCacheService::class)->invalidateByRelatedModel(Company::class, 5); +``` diff --git a/docs/src/pages/system-reference/backend/services/cache-concerns/cache-tags.md b/docs/src/pages/system-reference/backend/services/cache-concerns/cache-tags.md new file mode 100644 index 000000000..6c17884e0 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/cache-concerns/cache-tags.md @@ -0,0 +1,50 @@ +--- +sidebarPos: 1 +sidebarTitle: CacheTags +--- + +# CacheTags + +**File**: `src/Services/Concerns/CacheTags.php` + +`CacheTags` generates the tag name arrays used by every tagged cache operation. It defines a three-level tag hierarchy — global prefix, module, route — plus a relation-scoped tag for granular per-record invalidation. + +## Tag Hierarchy + +``` +{prefix} ← global tag (all modularity caches) +{prefix}:{Module} ← module tag (all routes in this module) +{prefix}:{Module}:{Route} ← route tag (one specific route/submodule) +{prefix}:rel:{ModelName}:{id} ← relation tag (caches referencing this record) +``` + +## Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `getModuleTags(string $moduleName, bool $onlyModule = false): array` | `['{prefix}:{Module}']` or `['{prefix}', '{prefix}:{Module}']` | Tags for a whole module. `onlyModule: true` omits the global prefix tag. | +| `getModuleRouteTags(string $moduleName, string $moduleRouteName, bool $onlyRoute = false): array` | Up to 3 tags | Tags for a specific route. `onlyRoute: true` returns only the route tag. | +| `getTypeTags(string $moduleName, string $moduleRouteName, string $type): array` | 4 tags | Full hierarchy down to type level (used for internal cache keys). | +| `generateRelationTag(string $modelClass, $id): string` | Single tag string | `{prefix}:rel:{ModelName}:{id}` — scoped to one record. | +| `generateRelationTags(array $relations): array` | Array of tag strings | Bulk version; accepts `['ModelClass' => id]` or `['ModelClass' => [id1, id2]]`. | + +## Examples + +```php +// All tags when writing a route-scoped cache entry +$tags = $this->getModuleRouteTags('Orders', 'order'); +// ['modularity', 'modularity:Orders', 'modularity:Orders:Order'] + +// Only route tag when doing a narrow invalidation +$tags = $this->getModuleRouteTags('Orders', 'order', onlyRoute: true); +// ['modularity:Orders:Order'] + +// Relation tags for a belongs-to-many write +$tags = $this->generateRelationTags(['Company' => 5, 'Tag' => [1, 3]]); +// ['modularity:rel:Company:5', 'modularity:rel:Tag:1', 'modularity:rel:Tag:3'] +``` + +## Notes + +- Module and route names are converted to StudlyCase via `Str::studly()` before generating tags, so `orders` and `Orders` produce the same tag. +- The `getPrefix()` abstract method must be implemented by the consuming class (returns `'modularity'` in `ModularityCacheService`). diff --git a/docs/src/pages/system-reference/backend/services/cache-concerns/overview.md b/docs/src/pages/system-reference/backend/services/cache-concerns/overview.md new file mode 100644 index 000000000..a0f9f2e88 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/cache-concerns/overview.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 2 +sidebarTitle: Overview +--- + +# Cache Concerns + +**Directory**: `src/Services/Concerns/` + +The Cache Concerns are three PHP traits that compose the caching behaviour used by `ModularityCacheService`. They separate the cache layer into distinct responsibilities: + +| Trait | File | Purpose | Page | +|-------|------|---------|------| +| [CacheTags](/system-reference/backend/services/cache-concerns/cache-tags) | `Concerns/CacheTags.php` | Generates tag name arrays for module, route, type, and relation scopes | [→](/system-reference/backend/services/cache-concerns/cache-tags) | +| [CacheHelpers](/system-reference/backend/services/cache-concerns/cache-helpers) | `Concerns/CacheHelpers.php` | `remember`, `get`, `put`, `forget`, `flush` — all tag-aware | [→](/system-reference/backend/services/cache-concerns/cache-helpers) | +| [CacheInvalidation](/system-reference/backend/services/cache-concerns/cache-invalidation) | `Concerns/CacheInvalidation.php` | Invalidation by module, route, model, pattern, or relation tag | [→](/system-reference/backend/services/cache-concerns/cache-invalidation) | + +## Composition + +``` +ModularityCacheService + └── uses CacheHelpers + └── uses CacheTags + └── uses CacheInvalidation + └── uses CacheTags + └── uses WarmupCache + └── uses ModularModel +``` + +`CacheHelpers` is the entry point — it composes `CacheTags` and `CacheInvalidation` into a single unified interface. `ModularityCacheService` only needs to `use CacheHelpers` to get all three. + +## Required Interface + +Classes using these traits must implement four abstract methods: + +```php +protected function getStore(): Repository; // cache store instance +protected function getPrefix(): string; // cache key prefix +protected function usesTags(): bool; // tag support detection +protected function isEnabled( + ?string $moduleName = null, + ?string $moduleRouteName = null, + ?string $type = null +): bool; // feature-flag check +``` diff --git a/docs/src/pages/system-reference/backend/services/cache-relationship-graph.md b/docs/src/pages/system-reference/backend/services/cache-relationship-graph.md new file mode 100644 index 000000000..b5b7ccf58 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/cache-relationship-graph.md @@ -0,0 +1,106 @@ +--- +sidebarPos: 10 +sidebarTitle: CacheRelationshipGraph +--- + +# CacheRelationshipGraph + +**File**: `src/Services/CacheRelationshipGraph.php` +**Cache key**: `modularity:cache:relationship_graph` + +`CacheRelationshipGraph` builds and caches a **dependency map** between Eloquent models and the module routes that display their data. When a model record changes, the cache service consults this graph to know exactly which module route caches need to be invalidated — instead of flushing everything. + +## Configuration + +```php +// config/modularity.php +'cache' => [ + 'graph' => [ + 'enabled' => true, + 'ttl' => 86400, // seconds (24 hours) + ], +], +``` + +## Graph Structure + +```php +[ + 'model_to_module_routes' => [ + 'Modules\Orders\Entities\Order' => [ + ['moduleName' => 'Orders', 'moduleRouteName' => 'order'], + ['moduleName' => 'Dashboard', 'moduleRouteName' => 'summary'], + ], + ], + 'table_to_module_routes' => [ + 'order_tags' => [ + ['moduleName' => 'Orders', 'moduleRouteName' => 'order'], + ], + ], + 'module_relationships' => [ + 'Orders' => [ + 'order' => [ + 'model_class' => 'Modules\Orders\Entities\Order', + 'relationships' => [...], + ], + ], + ], + 'submodule_to_module' => [...], +] +``` + +## Key Methods + +| Method | Description | +|--------|-------------| +| `getGraph(): array` | Returns the full graph (from cache or builds it) | +| `buildGraph(): array` | Scans all enabled modules and their entity relationships | +| `getAffectedModuleRoutes(string $modelClass): array` | Returns `[moduleName, moduleRouteName]` pairs affected by a model change | +| `getAffectedModuleRoutesByTable(string $tableName): array` | Same, keyed by raw table name (for pivot tables) | +| `rebuildGraph(): array` | Clears the cached graph and rebuilds it | +| `clearGraph(): void` | Deletes the cached graph without rebuilding | +| `getStats(): array` | Returns counts and mappings for debugging | +| `analyzeImpact(string $modelOrTable): array` | Given a model class or table name, returns which module routes are affected | +| `getVisualGraph(): array` | Returns a human-readable nested structure for debugging | +| `isCached(): bool` | Checks if the graph is currently in the cache store | +| `isEnabled(): bool` | Checks the `cache.graph.enabled` config flag | + +## How the Graph is Built + +`buildGraph()` iterates over all enabled modules via `Modularity::allEnabled()`. For each module, it scans the `Entities/` and `Models/` directories for concrete model classes. For each model: + +1. Calls `getEloquentRelationships()` on the model instance. +2. Records the `relationship_model`, `relationship_table`, `middleman_model`, `middleman_table` from each relation. +3. Populates `model_to_module_routes` and `table_to_module_routes` so invalidation can work both by model class and by raw table name. + +Models that do not implement `getEloquentRelationships()`, `getModuleName()`, or `getRouteName()` are skipped silently. + +## Usage in Cache Invalidation + +```php +// When Order model is saved: +$affectedRoutes = $graph->getAffectedModuleRoutes(Order::class); +// [['moduleName' => 'Orders', 'moduleRouteName' => 'order'], ...] + +foreach ($affectedRoutes as [$module, $route]) { + $cacheService->invalidateModuleRoute($module, $route); +} +``` + +## Debugging + +```php +// Inspect what models are tracked and their dependencies +$stats = app(CacheRelationshipGraph::class)->getStats(); + +// Get a visual breakdown per module +$visual = app(CacheRelationshipGraph::class)->getVisualGraph(); + +// Check impact of a specific model +$impact = app(CacheRelationshipGraph::class)->analyzeImpact(Order::class); +``` + +## Notes + +- The graph is cached for `cache.graph.ttl` seconds (default 24 hours). Run `cache:clear` or `rebuildGraph()` after adding new module relationships. +- If `isEnabled()` returns `false`, `getAffectedModuleRoutes()` and `getAffectedModuleRoutesByTable()` both return `[]`, disabling relationship-based invalidation entirely. diff --git a/docs/src/pages/system-reference/backend/services/connector.md b/docs/src/pages/system-reference/backend/services/connector.md new file mode 100644 index 000000000..ccd229a00 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/connector.md @@ -0,0 +1,102 @@ +--- +sidebarPos: 11 +sidebarTitle: Connector +--- + +# Connector + +**File**: `src/Services/Connector.php` + +The `Connector` class parses a **connector string** — a compact DSL that describes where a form input should fetch its data from. It is the backbone of all `connector:` keys in input field configurations, used by hydrates to resolve remote endpoints and repository calls. + +## Connector String Syntax + +``` +ModuleName|RouteName^TargetType->method?arg1=value1&arg2=value2 +``` + +| Segment | Separator | Meaning | +|---------|-----------|---------| +| `ModuleName` | `\|` | The registered Modularous module name | +| `RouteName` | `^` | The route key within that module (defaults to module name if omitted) | +| `TargetType` | `->` | `endpoint` / `url` / `uri` for a URL, or a class key such as `repository` | +| `method` | `?` | Method to call on the target (e.g. `list`) | +| `arg=value` | `&` | Named arguments passed to the method | +| `[a,b,c]` | — | Array argument value | +| `{key:val}` | — | Object/map argument value | + +### Target types + +| Type | Resolves to | +|------|-------------| +| `endpoint` / `url` / `uri` | A URL string — calls `getRouteActionUrl()` on the module. Sets `endpoint` in the result. | +| `repository` | The module's repository instance — subsequent method calls chain on it. Sets `items` in the result. | + +## Examples + +```php +// Returns the index URL of the PackageRegion route inside the Package module +'connector' => 'Package|PackageRegion^endpoint->index' + +// Calls repository->list() with named scopes and appends +'connector' => 'Package|PackageRegion^repository->list?scopes=hasVendablePackage&appends=[number_of_countries,number_of_package_languages]' + +// Array argument syntax +'connector' => 'Product^repository->list?appends=[price,currency]' + +// Object argument syntax +'connector' => 'Product^repository->list?filters={status:active,type:digital}' +``` + +## Key Methods + +| Method | Description | +|--------|-------------| +| `run(&$item, $setKey)` | Execute the connector and write the result into `$item[$setKey]` | +| `getModule()` | Return the resolved `Module` instance | +| `getModuleName()` | Return the module name string | +| `getRouteName()` | Return the resolved route name string | +| `getTarget()` | Return the resolved target object (module or repository instance) | +| `getTargetTypeKey()` | Return the target type string (`endpoint`, `repository`, etc.) | +| `isLinkTarget()` | Returns `true` when the target type is `endpoint`/`url`/`uri` | +| `pushEvent($event)` | Append an extra method call to the execution chain | +| `unshiftEvent($event)` | Prepend an extra method call to the execution chain | +| `pushEvents($events)` | Append multiple events at once | +| `unshiftEvents($events)` | Prepend multiple events at once | +| `updateEventParameters($name, $params)` | Merge additional arguments into a named event | +| `getEvents()` | Return the full event chain array | +| `getRepository($asClass)` | Resolve the module's repository for this route | +| `getModel($asClass)` | Resolve the module's model for this route | + +## Usage in Hydrates + +Hydrates that accept a `connector` config key (e.g. `SelectHydrate`, `RelationshipsHydrate`) instantiate `Connector` and call `run()` to populate the input's options or endpoint: + +```php +$connector = new Connector($config['connector']); +$connector->run($fieldSchema, 'endpoint'); +// $fieldSchema['endpoint'] now contains the resolved URL or item list +``` + +## Usage in Navigation + +`ModularityNavigation` uses `Connector` to resolve badge counts on sidebar menu items: + +```php +// In a module's menu config +'badge' => null, +'connector' => 'Orders^repository->count?scopes=pending', +``` + +The connector resolves the count and injects it as `badge` into the menu item array. + +## Error Handling + +The constructor throws exceptions for invalid connector strings: + +| Exception | Cause | +|-----------|-------| +| `ModuleNotFoundException::moduleMissing` | Module name is empty | +| `ModuleNotFoundException::moduleNotFound` | Module is not registered | +| `ModuleNotFoundException::routeNotFound` | Route key does not exist in the module | +| `\Exception` | Class resolved for target type does not exist | diff --git a/docs/src/pages/system-reference/backend/services/coverage-service.md b/docs/src/pages/system-reference/backend/services/coverage-service.md new file mode 100644 index 000000000..338a2ce18 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/coverage-service.md @@ -0,0 +1,92 @@ +--- +sidebarPos: 12 +sidebarTitle: CoverageService +--- + +# CoverageService + +**File**: `src/Services/CoverageService.php` +**Bound as**: `coverage.service` +**Config**: `config/modularity-coverage.php` + +`CoverageService` is a developer tool that parses PHPUnit Clover XML coverage reports and exposes methods for analysing uncovered code, generating reports (JSON / Markdown / HTML), and running PR coverage checks. + +## Configuration + +```php +// config/modularity-coverage.php +return [ + 'clover_dir' => base_path(), // directory containing the Clover XML file + 'clover_name' => 'clover.xml', // Clover file name +]; +``` + +## Instantiation + +```php +// Via service container (uses config defaults) +$coverage = app('coverage.service'); + +// Static factory with custom path +$coverage = CoverageService::make('/path/to/project', 'custom-clover.xml'); + +// Singleton (cached) +$coverage = CoverageService::instance(); +``` + +## Fluent Configuration + +| Method | Description | +|--------|-------------| +| `setCloverPath(string $path)` | Override the Clover directory | +| `setCloverName(string $name)` | Override the Clover file name | +| `filterByFiles(array $files)` | Restrict analysis to specific file paths | +| `setCoverageThreshold(float $threshold)` | Only report methods below this coverage % | +| `skipMagicMethods(bool $skip)` | Exclude `__construct`, `__get`, etc. | +| `skipPrivateMethods(bool $skip)` | Exclude private methods | +| `skipProtectedMethods(bool $skip)` | Exclude protected methods | + +All fluent methods return `$this` for chaining. + +## Analysis Methods + +| Method | Description | +|--------|-------------| +| `analyze(): array` | Full analysis — returns array of under-covered methods | +| `analyzeFile(string $filePath): array` | Analyse a single file | +| `getMethodCoverage(string $filePath, string $methodName): ?array` | Coverage data for one method | +| `uncovered(array $files = []): array` | Methods with 0% coverage | +| `partial(float $threshold = 50.0, array $files = []): array` | Methods below threshold | +| `stats(?array $files = null): array` | Summary statistics | +| `git(string $baseBranch = '0.x'): array` | Analyse only files changed vs base branch | +| `checkPR(string $baseBranch = 'main', bool $throwOnFailure = false): bool` | Returns `true` if all changed files meet coverage requirements | + +## Report Generation + +| Method | Output | +|--------|--------| +| `json(?array $files, bool $prettyPrint = true): string` | JSON report string | +| `markdown(?array $files): string` | Markdown report string | +| `html(?array $files): string` | Self-contained HTML report string | +| `save(string $outputPath, ?array $files, string $format = 'json'): bool` | Save report to file (`json`, `markdown`, `html`) | + +## Example + +```php +// Find all uncovered methods +$uncovered = CoverageService::make()->uncovered(); + +// Generate and save a Markdown report +CoverageService::make() + ->filterByFiles(['src/Services/Connector.php']) + ->save(storage_path('coverage-report.md'), format: 'markdown'); + +// Fail a CI step if changed files are not covered +CoverageService::make()->checkPR('main', throwOnFailure: true); +``` + +## Notes + +- `getErrors()` / `hasErrors()` expose any XML parsing errors from the underlying `CoverageAnalyzer`. +- `git()` uses `shell_exec('git diff --name-only ...')` to discover changed files; the `cloverDir` must be a git repository. +- The singleton (`instance()`) is reset with `clearInstance()`, useful in tests. diff --git a/docs/src/pages/system-reference/backend/services/currency-exchange-service.md b/docs/src/pages/system-reference/backend/services/currency-exchange-service.md new file mode 100644 index 000000000..7109ccb2b --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/currency-exchange-service.md @@ -0,0 +1,86 @@ +--- +sidebarPos: 13 +sidebarTitle: CurrencyExchangeService +--- + +# CurrencyExchangeService + +**File**: `src/Services/CurrencyExchangeService.php` +**Facade**: `Unusualify\Modularity\Facades\CurrencyExchange` + +Fetches live currency exchange rates from a configurable external API, caches the result for one hour, and provides amount conversion helpers. + +## Configuration + +All options live under `config/modularity.php` → `services.currency_exchange`: + +```php +'services' => [ + 'currency_exchange' => [ + 'endpoint' => 'https://api.freecurrencyapi.com/v1/latest', + 'api_key' => env('CURRENCY_EXCHANGE_API_KEY'), + 'base_currency' => 'EUR', + + // Map of service property names → query parameter names + 'parameters' => [ + 'apiKey' => 'apikey', + 'baseCurrency' => 'base_currency', + ], + + // JSON key in the API response that holds the rates object + 'rates_key' => 'data', + ], +], +``` + +## Key Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `fetchExchangeRates` | `fetchExchangeRates(): array` | Fetch rates from the API and cache for 1 hour. Returns `[currency => rate]`. Throws on API failure. | +| `convertTo` | `convertTo(float $amount, string $currency, int $decimals, string $round): float` | Convert `$amount` from the base currency to `$currency`. | +| `getExchangeRate` | `getExchangeRate(string $currency, int $decimals, string $round): float` | Return the exchange rate for a single currency. | + +## Rounding + +The `$round` parameter on `convertTo()` and `getExchangeRate()` controls how the result is rounded: + +| Value | Behaviour | +|-------|-----------| +| `'round'` (default) | `round($value, $decimals)` | +| `'ceil'` | `ceil($value)` | +| `'floor'` | `floor($value)` | + +## Cache + +Rates are cached using the key `exchange_rates` for **1 hour** via `Cache::remember`. To force a refresh, clear this key: + +```bash +php artisan cache:forget exchange_rates +``` + +## Facade Usage + +```php +use Unusualify\Modularity\Facades\CurrencyExchange; + +// Convert 100 EUR to USD +$usd = CurrencyExchange::convertTo(100, 'USD'); + +// Get the raw rate for TRY +$rate = CurrencyExchange::getExchangeRate('TRY', decimals: 4); + +// Convert, ceiling the result +$price = CurrencyExchange::convertTo($euroPrice, 'GBP', decimals: 2, round: 'ceil'); +``` + +## Currency Providers + +The `CurrencyExchangeService` is supplemented by currency provider classes used in the pricing module: + +| Class | Description | +|-------|-------------| +| `Currency/SystemPricingCurrencyProvider` | Fetches the configured currency from the system pricing module | +| `Currency/NullCurrencyProvider` | No-op provider returned when no pricing module is active | + +The active provider is resolved via `modularity.currency_provider` config key. diff --git a/docs/src/pages/system-reference/backend/services/currency/custom-provider.md b/docs/src/pages/system-reference/backend/services/currency/custom-provider.md new file mode 100644 index 000000000..424d34e14 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/currency/custom-provider.md @@ -0,0 +1,208 @@ +--- +sidebarPos: 4 +sidebarTitle: Custom Provider +outline: deep +--- + +# Building a Custom Currency Provider + +Implement `CurrencyProviderInterface` when neither `NullCurrencyProvider` nor `SystemPricingCurrencyProvider` fits — e.g. you want to back currency data with a third-party API, a different Eloquent model, or a hard-coded list. + +**Time**: ~10 minutes for a working provider. + +## Checklist + +1. Implement the three interface methods in a new class. +2. Bind your class to the interface in a service provider. +3. (Optional) Cache expensive reads. +4. Verify by resolving from the container and calling each method. + +## Step 1 — Scaffold the class + +Place the class wherever fits your project layout — for this example, `app/Services/Currency/`: + +```php +<?php + +declare(strict_types=1); + +namespace App\Services\Currency; + +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; +use Unusualify\Modularity\Contracts\CurrencyProviderInterface; + +class ExternalApiCurrencyProvider implements CurrencyProviderInterface +{ + public function findByIso4217(string $isoCode): ?object + { + $iso = mb_strtoupper($isoCode); + + return Cache::remember( + "external_currency_{$iso}", + now()->addHours(6), + function () use ($iso) { + $response = Http::get("https://example.com/api/currencies/{$iso}"); + + if (! $response->ok()) { + return null; + } + + return (object) $response->json(); + // Expected shape: ['id' => int, 'iso_4217' => 'USD', 'symbol' => '$', ...] + }, + ); + } + + public function getCurrenciesForSelect(): array + { + return Cache::remember( + 'external_currencies_list', + now()->addHours(6), + function () { + $response = Http::get('https://example.com/api/currencies'); + + if (! $response->ok()) { + return []; + } + + return collect($response->json()) + ->map(fn ($row) => [ + 'id' => $row['id'], + 'name' => $row['symbol'], + 'iso' => $row['iso_4217'], + ]) + ->all(); + }, + ); + } + + public function isAvailable(): bool + { + return ! empty(config('services.example_currency.api_key')); + } +} +``` + +## Step 2 — Bind in a service provider + +Add to `AppServiceProvider::register()` (or any other service provider that runs before the HTTP kernel): + +```php +use App\Services\Currency\ExternalApiCurrencyProvider; +use Unusualify\Modularity\Contracts\CurrencyProviderInterface; + +public function register(): void +{ + $this->app->bind( + CurrencyProviderInterface::class, + ExternalApiCurrencyProvider::class, + ); +} +``` + +Use `singleton()` instead of `bind()` if your implementation holds state between calls (e.g. an in-memory cache): + +```php +$this->app->singleton(CurrencyProviderInterface::class, ExternalApiCurrencyProvider::class); +``` + +### Binding conditionally + +If you want to fall back to the package default when your configuration is incomplete: + +```php +$this->app->bind(CurrencyProviderInterface::class, function ($app) { + if (empty(config('services.example_currency.api_key'))) { + return new \Unusualify\Modularity\Services\Currency\NullCurrencyProvider; + } + + return new ExternalApiCurrencyProvider; +}); +``` + +## Step 3 — Verify + +Tinker session: + +```php +php artisan tinker + +>>> $p = app(\Unusualify\Modularity\Contracts\CurrencyProviderInterface::class); +>>> get_class($p); +=> "App\\Services\\Currency\\ExternalApiCurrencyProvider" + +>>> $p->isAvailable(); +=> true + +>>> $p->findByIso4217('USD'); +=> {#... id: 1, iso_4217: "USD", symbol: "$" ...} + +>>> $p->getCurrenciesForSelect(); +=> [['id' => 1, 'name' => '$', 'iso' => 'USD'], ...] +``` + +## Return-Shape Contract + +Other Modularous code only relies on three things — keep them stable: + +| Method | Non-null return must have… | +|--------|---------------------------| +| `findByIso4217()` | Either an object with **public readable fields** or an Eloquent model. Consumers typically read `->id`, `->iso_4217`, `->symbol` / `->name`. | +| `getCurrenciesForSelect()` | Array of arrays with keys exactly `id`, `name`, `iso`. | +| `isAvailable()` | Strict `bool`. Return `false` **fast** — callers may check this in hot paths. | + +Break the shape and consumers (select inputs, `PricesTrait`) silently misbehave. + +## Testing Your Provider + +```php +use Illuminate\Support\Facades\Http; +use Tests\TestCase; +use Unusualify\Modularity\Contracts\CurrencyProviderInterface; + +class ExternalApiCurrencyProviderTest extends TestCase +{ + public function test_it_returns_usd(): void + { + Http::fake([ + 'example.com/api/currencies/USD' => Http::response([ + 'id' => 42, + 'iso_4217' => 'USD', + 'symbol' => '$', + ]), + ]); + + $currency = app(CurrencyProviderInterface::class)->findByIso4217('usd'); + + $this->assertSame('USD', $currency->iso_4217); + $this->assertSame('$', $currency->symbol); + } +} +``` + +## Replacing `SystemPricingCurrencyProvider` + +If the SystemPricing module is installed but you want to override its binding: + +1. Register your provider in a service provider with a **higher** `$defer` / load priority than SystemPricing's. +2. Or call `$this->app->extend()` from `AppServiceProvider::boot()`: + +```php +public function boot(): void +{ + $this->app->extend( + CurrencyProviderInterface::class, + fn ($existing, $app) => new ExternalApiCurrencyProvider, + ); +} +``` + +`boot()` runs after all `register()` hooks, so your binding wins deterministically. + +## See Also + +- [Overview](./overview) — how provider selection works +- [Usage Patterns](./usage-patterns) — consuming the provider +- [NullCurrencyProvider](./null-currency-provider) — reference fallback +- [SystemPricingCurrencyProvider](./system-pricing-currency-provider) — reference implementation diff --git a/docs/src/pages/system-reference/backend/services/currency/null-currency-provider.md b/docs/src/pages/system-reference/backend/services/currency/null-currency-provider.md new file mode 100644 index 000000000..f24f7aa8a --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/currency/null-currency-provider.md @@ -0,0 +1,31 @@ +--- +sidebarPos: 1 +sidebarTitle: NullCurrencyProvider +--- + +# NullCurrencyProvider + +**File**: `src/Services/Currency/NullCurrencyProvider.php` +**Implements**: `CurrencyProviderInterface` + +`NullCurrencyProvider` is the default fallback implementation used when no pricing module is installed. All methods return safe empty values so that code calling `CurrencyExchangeService` does not need to null-check the provider. + +## Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `findByIso4217(string $isoCode): ?object` | `null` | Always returns null — no currency model available | +| `getCurrenciesForSelect(): array` | `[]` | Returns empty array — no currencies to select | +| `isAvailable(): bool` | `false` | Signals that currency functionality is not available | + +## When It Is Used + +The `NullCurrencyProvider` is bound automatically when the `SystemPricing` module (or any other module that provides a `CurrencyProviderInterface` binding) is not installed. Downstream code should check `isAvailable()` before relying on currency data: + +```php +$provider = app(CurrencyProviderInterface::class); + +if ($provider->isAvailable()) { + $currency = $provider->findByIso4217('USD'); +} +``` diff --git a/docs/src/pages/system-reference/backend/services/currency/overview.md b/docs/src/pages/system-reference/backend/services/currency/overview.md new file mode 100644 index 000000000..6ad7b5099 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/currency/overview.md @@ -0,0 +1,137 @@ +--- +sidebarPos: 3 +sidebarTitle: Overview +sidebarGroupTitle: Currency +outline: deep +--- + +# Currency Providers + +**Directory**: `src/Services/Currency/` +**Contract**: `Unusualify\Modularity\Contracts\CurrencyProviderInterface` + +The Currency namespace provides interchangeable implementations of `CurrencyProviderInterface` — the abstraction used when Modularous needs to resolve currency records from the database (e.g. for price conversion, multi-currency selects, and localisation). The active provider is resolved from the service container and swapped depending on which modules are installed. + +Together with [`CurrencyExchangeService`](/system-reference/backend/services/currency-exchange-service) (live exchange-rate fetching) and the [`CurrencyExchange`](/system-reference/backend/facades/currency-exchange) facade, these pieces cover everything currency-related in Modularous: + +| Piece | Responsibility | +|-------|---------------| +| `CurrencyProviderInterface` + implementations | **Data access** — look up currency records stored in the database | +| `CurrencyExchangeService` | **Rate fetching** — hit an external API, cache rates, convert amounts | +| `CurrencyExchange` facade | Shorthand for the exchange service | +| `CurrencyExchangeController` | HTTP endpoints that expose the exchange service | + +## In This Section + +| Page | Purpose | +|------|---------| +| **Overview** (this page) | When each provider is used, configuration, binding | +| [NullCurrencyProvider](./null-currency-provider) | Default fallback — safe empty returns | +| [SystemPricingCurrencyProvider](./system-pricing-currency-provider) | Reads from the `SystemPricing` module's `Currency` entity | +| [Usage Patterns](./usage-patterns) | Common ways to call the provider from controllers, repositories, and views | +| [Custom Provider](./custom-provider) | Step-by-step guide to implementing your own provider | + +--- + +## How Provider Selection Works + +``` + ┌─────────────────────────┐ +CurrencyProvider │ CurrencyProviderInterface │ + └───────────┬─────────────┘ + │ resolve() + ┌──────────┴──────────┐ + │ │ + SystemPricing enabled? no pricing module + │ │ + ▼ ▼ + SystemPricingCurrencyProvider NullCurrencyProvider +``` + +1. A module provides its own binding in a service provider (e.g. `SystemPricing` binds `SystemPricingCurrencyProvider`). +2. If no module provides one, `BaseServiceProvider` falls back to `NullCurrencyProvider`. +3. Downstream code **always** resolves the interface — it never references a concrete class: + +```php +$provider = app(CurrencyProviderInterface::class); +``` + +## CurrencyProviderInterface + +```php +interface CurrencyProviderInterface +{ + public function findByIso4217(string $isoCode): ?object; + public function getCurrenciesForSelect(): array; + public function isAvailable(): bool; +} +``` + +| Method | Contract | +|--------|----------| +| `findByIso4217(string $isoCode): ?object` | Return the currency record whose ISO 4217 code matches (case-insensitive), or `null` when it does not exist / the provider is not available. | +| `getCurrenciesForSelect(): array` | Return `[['id' => int, 'name' => string, 'iso' => string], ...]` — the shape consumed by Modularous select inputs. Return `[]` when the provider is not available. | +| `isAvailable(): bool` | `true` when the provider can return real data, `false` when it is the null fallback. Call this **before** relying on return values. | + +::: tip Why `?object`? +The interface deliberately returns `?object` rather than a concrete `Currency` model class. Implementations may back the data with different Eloquent models (or any value object) — the interface simply guarantees the three methods. +::: + +## Configuration + +```php +// config/modularity.php (merged from packages/modularous/config/merges/services.php) +'services' => [ + 'currency_exchange' => [ + 'active' => env('CURRENCY_EXCHANGE_ACTIVE', true), + 'api_key' => env('CURRENCY_EXCHANGE_API_KEY'), + 'base_currency' => 'EUR', + 'endpoint' => 'https://api.freecurrencyapi.com/v1/latest', + 'parameters' => [ + 'apiKey' => 'apikey', + 'baseCurrency' => 'base_currency', + ], + 'rates_key' => 'data', + ], +], +``` + +### Effect of `services.currency_exchange.active` + +| `active` | Behaviour | +|----------|-----------| +| `true` (default) | `getCurrenciesForSelect()` returns **only the base currency**. Forms expose one currency choice and the repository auto-converts to the others using `CurrencyExchangeService`. | +| `false` | `getCurrenciesForSelect()` returns **every currency**. Forms let the operator pick any currency directly; no auto-conversion. | + +This is the key toggle that controls whether the system is effectively single-currency-with-conversion or multi-currency-manual. + +## Binding a Custom Provider + +Bind from a service provider **before** `BaseServiceProvider::register()` runs — or use `$this->app->extend()` to replace the already-bound instance: + +```php +// AppServiceProvider::register() +$this->app->bind( + \Unusualify\Modularity\Contracts\CurrencyProviderInterface::class, + \App\Services\MyCurrencyProvider::class, +); +``` + +For a walkthrough with a working example, see [Custom Provider](./custom-provider). + +## Quick Reference + +| I want to… | Use | +|------------|-----| +| Look up a currency by ISO code | `app(CurrencyProviderInterface::class)->findByIso4217('USD')` | +| Populate a currency `<select>` | `app(CurrencyProviderInterface::class)->getCurrenciesForSelect()` | +| Check whether currencies are available before rendering | `$provider->isAvailable()` | +| Convert an amount at live rates | `CurrencyExchange::convertTo($amount, 'USD')` | +| Fetch the raw rates table (cached 1h) | `CurrencyExchange::fetchExchangeRates()` | + +## See Also + +- [CurrencyExchangeService](/system-reference/backend/services/currency-exchange-service) — live rate fetcher +- [CurrencyExchange facade](/system-reference/backend/facades/currency-exchange) — shorthand access +- [CurrencyExchangeController](/system-reference/backend/http/controllers/currency-exchange-controller) — HTTP endpoints +- [PricesTrait](/system-reference/backend/repository-traits/payment) — repository trait that drives multi-currency persistence diff --git a/docs/src/pages/system-reference/backend/services/currency/system-pricing-currency-provider.md b/docs/src/pages/system-reference/backend/services/currency/system-pricing-currency-provider.md new file mode 100644 index 000000000..638a8d1b1 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/currency/system-pricing-currency-provider.md @@ -0,0 +1,47 @@ +--- +sidebarPos: 2 +sidebarTitle: SystemPricingCurrencyProvider +--- + +# SystemPricingCurrencyProvider + +**File**: `src/Services/Currency/SystemPricingCurrencyProvider.php` +**Implements**: `CurrencyProviderInterface` +**Requires**: `Modules\SystemPricing\Entities\Currency` (SystemPricing module) + +`SystemPricingCurrencyProvider` reads currency data from the `SystemPricing` module's `Currency` entity. All database queries are cached for 1 hour using Laravel's default cache store. + +## Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `findByIso4217(string $isoCode): ?object` | `Currency` model or `null` | Looks up a currency by ISO 4217 code (e.g. `'USD'`). Result cached as `currency_by_iso_4217_{code}`. | +| `findById(int $id): ?object` | `Currency` model or `null` | Looks up a currency by primary key. Result cached as `currency_by_id_{id}`. | +| `getCurrenciesForSelect(): array` | `[['id', 'name', 'iso'], ...]` | Returns all currencies as a flat array for select inputs. When `services.currency_exchange.active` is `true`, only returns currencies matching the configured base currency. | +| `isAvailable(): bool` | `true` when `Currency` class exists | Returns `false` if the `SystemPricing` module is not installed or the `Currency` entity cannot be resolved. | + +## Cache Keys + +| Key | TTL | Content | +|-----|-----|---------| +| `currency_by_iso_4217_{code}` | 1 hour | Single `Currency` model | +| `currency_by_id_{id}` | 1 hour | Single `Currency` model | + +## Configuration + +```php +// config/modularity.php +'services' => [ + 'currency_exchange' => [ + 'active' => true, + 'base_currency' => 'EUR', + ], +], +``` + +When `active` is `true`, `getCurrenciesForSelect()` filters to only return the base currency, limiting conversion targets to one. + +## Notes + +- Each method checks `class_exists(Currency::class)` before querying. If the `SystemPricing` module is disabled mid-request, the methods return `null` / `[]` without throwing. +- Implement a custom provider and bind it to `CurrencyProviderInterface` in a service provider to replace this with a different data source. diff --git a/docs/src/pages/system-reference/backend/services/currency/usage-patterns.md b/docs/src/pages/system-reference/backend/services/currency/usage-patterns.md new file mode 100644 index 000000000..a3f7758fa --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/currency/usage-patterns.md @@ -0,0 +1,182 @@ +--- +sidebarPos: 3 +sidebarTitle: Usage Patterns +outline: deep +--- + +# Currency Usage Patterns + +Practical patterns for consuming `CurrencyProviderInterface` and the `CurrencyExchange` facade from controllers, repositories, Blade/Vue views, and tests. Each pattern pairs the **shortest working snippet** with notes on pitfalls. + +## 1. Feature-flag UI on availability + +Always check `isAvailable()` before rendering a currency picker or price-converting UI. When the SystemPricing module is not installed, the fallback provider returns empty data — you want the UI to degrade gracefully instead of showing an empty `<select>`. + +```php +use Unusualify\Modularity\Contracts\CurrencyProviderInterface; + +$provider = app(CurrencyProviderInterface::class); + +if (! $provider->isAvailable()) { + return view('admin.dashboard', ['currencies' => null]); +} + +return view('admin.dashboard', [ + 'currencies' => $provider->getCurrenciesForSelect(), +]); +``` + +In Blade: + +```blade +@if ($currencies !== null) + <select name="currency"> + @foreach ($currencies as $currency) + <option value="{{ $currency['iso'] }}">{{ $currency['name'] }}</option> + @endforeach + </select> +@else + <p class="text-muted">Currency selection requires the SystemPricing module.</p> +@endif +``` + +## 2. Resolve a currency by ISO code + +```php +$currency = app(CurrencyProviderInterface::class)->findByIso4217('USD'); + +if (! $currency) { + throw new \RuntimeException('USD is not configured'); +} + +$id = $currency->id; +$name = $currency->name ?? $currency->symbol ?? 'USD'; +``` + +Result is **cached for 1 hour** by `SystemPricingCurrencyProvider`. To bust the cache after editing a currency, call `Cache::forget("currency_by_iso_4217_USD")` or clear the `modularity` cache (`php artisan modularity:cache:clear`). + +## 3. Populate a form select + +The `getCurrenciesForSelect()` shape matches what Modularous' schema-driven form inputs expect for `options`: + +```php +// In a route config or form builder +[ + 'name' => 'currency_id', + 'type' => 'select', + 'options' => app(CurrencyProviderInterface::class)->getCurrenciesForSelect(), + 'item_value' => 'id', + 'item_title' => 'name', +], +``` + +When `services.currency_exchange.active` is `true`, the select contains **only the base currency** — that's by design. The price repository will then auto-convert to all enabled currencies on save. + +## 4. Convert at display time (live rates) + +`CurrencyExchangeService` hits the configured exchange API and caches the full rates table for 1 hour. + +```php +use Unusualify\Modularity\Facades\CurrencyExchange; + +$priceEur = 99.00; +$priceUsd = CurrencyExchange::convertTo($priceEur, 'USD'); // rounded, 2 dp +$priceUsd = CurrencyExchange::convertTo($priceEur, 'USD', 0); // rounded, 0 dp +$priceUsd = CurrencyExchange::convertTo($priceEur, 'USD', 2, 'ceil'); +``` + +::: warning Unsupported currencies throw +`convertTo()` throws `\Exception` when the target ISO is not present in the rates response. Wrap it in a `try` block in controllers that accept user-supplied currencies. +::: + +## 5. Read raw exchange rates + +```php +$rates = CurrencyExchange::fetchExchangeRates(); +// ['USD' => 1.08, 'GBP' => 0.86, ...] + +$usdRate = CurrencyExchange::getExchangeRate('USD'); +``` + +The rates table is keyed by ISO 4217. The base currency is whatever is configured in `services.currency_exchange.base_currency` (default `EUR`). + +## 6. Call from the frontend via HTTP + +Modularous ships these endpoints via `CurrencyExchangeController` (see `routes/front.php`): + +| Method | URI | Purpose | +|--------|-----|---------| +| `POST` | `/currency/fetch-rates` | Warm / refresh the rates cache | +| `POST` | `/currency/convert` | `{ amount, currency }` → `{ converted_amount, exchange_rate }` | +| `GET` | `/currency/rate/{currency}` | `{ currency, rate }` | + +Use from Vue / Axios: + +```js +const { data } = await axios.post('/currency/convert', { + amount: 99, + currency: 'USD', +}) +// data.converted_amount, data.exchange_rate +``` + +## 7. Use in `PricesTrait` (repository) + +When your repository uses `PricesTrait` (e.g. via `HasPriceable`), the trait reads the active provider automatically: + +- If `services.currency_exchange.active` is `true`: only the base-currency price is persisted from the form, and prices for **every enabled currency** are generated via `CurrencyExchange::convertTo()`. +- If `active` is `false`: whatever prices the form submitted are persisted as-is. + +You rarely call the provider directly in this flow — just configure the flag. See [PricesTrait](/system-reference/backend/repository-traits/payment) for the detailed lifecycle. + +## 8. Swap providers in tests + +`CurrencyProviderInterface` is a contract, so tests can bind a stub: + +```php +use Unusualify\Modularity\Contracts\CurrencyProviderInterface; + +class FakeCurrencyProvider implements CurrencyProviderInterface +{ + public function findByIso4217(string $isoCode): ?object + { + return (object) ['id' => 1, 'iso_4217' => 'EUR', 'symbol' => '€']; + } + + public function getCurrenciesForSelect(): array + { + return [['id' => 1, 'name' => '€', 'iso' => 'EUR']]; + } + + public function isAvailable(): bool + { + return true; + } +} + +$this->app->instance(CurrencyProviderInterface::class, new FakeCurrencyProvider); +``` + +For the exchange service, fake the HTTP layer: + +```php +Http::fake([ + '*' => Http::response(['data' => ['USD' => 1.08, 'GBP' => 0.86]]), +]); +``` + +## Common Pitfalls + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Empty currency `<select>` in prod only | `SystemPricing` module not installed → `NullCurrencyProvider` active | Install the module or bind a custom provider | +| `Unsupported currency: XYZ` exception | ISO not in the rates response | Catch + fall back to the base currency, or validate ISO first | +| Stale rates after manual edit | 1-hour cache | `Cache::forget('exchange_rates')` or `php artisan cache:clear` | +| Only EUR shows in admin dropdown | `services.currency_exchange.active` is `true` (intended — conversion handles the rest) | Set `active` → `false` to expose all currencies | +| `getCurrenciesForSelect()` returns `[]` in tests | No binding for the interface in the test container | Bind a fake provider as shown above | + +## See Also + +- [Overview](./overview) — selection rules, configuration, binding +- [Custom Provider](./custom-provider) — build your own `CurrencyProviderInterface` +- [CurrencyExchangeService](/system-reference/backend/services/currency-exchange-service) diff --git a/docs/src/pages/system-reference/backend/services/file-library/disk.md b/docs/src/pages/system-reference/backend/services/file-library/disk.md new file mode 100644 index 000000000..a86ed6c08 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/file-library/disk.md @@ -0,0 +1,54 @@ +--- +sidebarPos: 2 +sidebarTitle: Disk +--- + +# Disk + +**File**: `src/Services/FileLibrary/Disk.php` +**Implements**: `FileServiceInterface` +**Config value**: `disk` (default driver) + +The `Disk` driver is the default FileLibrary implementation. It delegates URL generation to Laravel's filesystem via `Storage::disk()->url()`, using whichever disk is configured under `file_library.disk`. + +## Configuration + +```php +// config/modularity.php +'file_library' => [ + 'disk' => env('FILE_LIBRARY_DISK', 'public'), +], +``` + +Set `FILE_LIBRARY_DISK` to any Laravel filesystem disk name (`public`, `s3`, `local`, etc.). + +## How It Works + +```php +public function getUrl($id) +{ + return $this->filesystemManager + ->disk(config('modularity.file_library.disk')) + ->url($id); +} +``` + +The driver reads the configured disk name at call time, then calls Laravel's standard `disk()->url($id)` — the same mechanism used for storage links. This means the URL format (relative vs absolute, with or without CDN prefix) is determined entirely by the disk's own driver configuration. + +## Usage + +```php +use Unusualify\Modularity\Facades\FileService; + +// Store a file and record its path +$path = Storage::disk('public')->putFile('documents', $request->file('doc')); + +// Retrieve its public URL later +$url = FileService::getUrl($path); +``` + +## Notes + +- The driver does not apply any transformations to the file. +- For S3 or other cloud disks, ensure the disk is configured with a public visibility or a CDN URL in `filesystems.disks.{name}.url`. +- To swap the driver, bind a different `FileServiceInterface` implementation to `fileService` in a service provider. diff --git a/docs/src/pages/system-reference/backend/services/file-library/file-service.md b/docs/src/pages/system-reference/backend/services/file-library/file-service.md new file mode 100644 index 000000000..edd0c4306 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/file-library/file-service.md @@ -0,0 +1,55 @@ +--- +sidebarPos: 1 +sidebarTitle: FileServiceInterface +--- + +# FileServiceInterface + +**File**: `src/Services/FileLibrary/FileServiceInterface.php` + +Contract that all FileLibrary drivers must implement. Currently defines a single method for retrieving a file URL by its stored identifier. + +## Interface Definition + +```php +interface FileServiceInterface +{ + public function getUrl(string $id): string; +} +``` + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getUrl` | `getUrl($id): string` | Returns the public URL for the file identified by `$id` | + +## Implementing a Custom Driver + +```php +use Unusualify\Modularity\Services\FileLibrary\FileServiceInterface; + +class MyCloudStorage implements FileServiceInterface +{ + public function getUrl($id): string + { + return 'https://cdn.example.com/files/' . $id; + } +} +``` + +Register the custom driver in a service provider: + +```php +$this->app->bind('fileService', MyCloudStorage::class); +``` + +## Facade + +The `FileService` Laravel Facade resolves to whatever class is bound to `fileService` in the container. The default binding is the [Disk](/system-reference/backend/services/file-library/disk) driver. + +```php +use Unusualify\Modularity\Facades\FileService; + +$url = FileService::getUrl($file->uuid); +``` diff --git a/docs/src/pages/system-reference/backend/services/file-library/overview.md b/docs/src/pages/system-reference/backend/services/file-library/overview.md new file mode 100644 index 000000000..ad9f5c1e6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/file-library/overview.md @@ -0,0 +1,36 @@ +--- +sidebarPos: 4 +sidebarTitle: Overview +--- + +# FileLibrary Services + +**Directory**: `src/Services/FileLibrary/` +**Facade**: `Unusualify\Modularity\Facades\FileService` (bound as `fileService`) + +The FileLibrary namespace provides a storage abstraction for **non-image file assets** — PDFs, documents, spreadsheets, and other binary files. It mirrors the MediaLibrary pattern with an interface + driver architecture. + +## Classes + +| Class | Description | Page | +|-------|-------------|------| +| [FileServiceInterface](/system-reference/backend/services/file-library/file-service) | Contract that all file storage drivers must implement | — | +| [FileService](/system-reference/backend/services/file-library/file-service) | Laravel Facade resolving to the `fileService` binding | — | +| [Disk](/system-reference/backend/services/file-library/disk) | Default driver — serves files from a configured Laravel disk | [Disk →](/system-reference/backend/services/file-library/disk) | + +## Configuration + +```php +// config/modularity.php +'file_library' => [ + 'disk' => env('FILE_LIBRARY_DISK', 'public'), +], +``` + +## Facade Usage + +```php +use Unusualify\Modularity\Services\FileLibrary\FileService; + +$url = FileService::getUrl($file->uuid); +``` diff --git a/docs/src/pages/system-reference/backend/services/file-translation.md b/docs/src/pages/system-reference/backend/services/file-translation.md new file mode 100644 index 000000000..49423e00c --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/file-translation.md @@ -0,0 +1,136 @@ +--- +sidebarPos: 15 +sidebarTitle: FileTranslation +--- + +# FileTranslation + +**File**: `src/Services/FileTranslation.php` +**Namespace**: `Unusualify\Modularity\Services` +**Extends**: `JoeDixon\Translation\Drivers\File` + +Concrete file-based translation driver used by Modularous. Adds **cross-path sync** on top of the upstream `File` driver — comparing translation files in two different directories and copying missing keys from one to the other. Powers the [`modularity:sync:translations`](/guide/console/sync/sync-translations) Artisan command, which pushes the package's bundled language files into the host application's `lang/` directory. + +Its transitive parent, [`Translation`](./translation), supplies scanning and source-locale merge utilities; this class focuses on file I/O and diff. + +## Class Signature + +```php +class FileTranslation extends File +{ + public $disk; + public $languageFilesPath; + public $sourceLanguage; + public $scanner; + + public function __construct(Filesystem $disk, $languageFilesPath, $sourceLanguage, $scanner); +} +``` + +The constructor re-exposes the parent's four dependencies as public properties so the instance can spawn temporary clones pointing at different `$languageFilesPath` values (used throughout the cross-path helpers). + +## Language Listing + +| Method | Signature | Returns | +|--------|-----------|---------| +| `getLanguagesExcept` | `(array $languages): array` | All available languages **minus** the given list | +| `getLanguagesOnly` | `(array $languages): array` | **Intersection** of available languages and the given list | + +Both wrap `parent::allLanguages()` with `array_diff` / `array_intersect`. + +## Cross-Path Helpers + +All of the following spawn ephemeral `FileTranslation` instances bound to arbitrary paths — this is the pattern that makes package ↔ app file sync possible without reconfiguring the Laravel service container. + +### `getTranslationsFromPath($languageFilesPath, $language): Collection` + +```php +$tempInstance = new static($this->disk, $languageFilesPath, $this->sourceLanguage, $this->scanner); +return $tempInstance->allTranslationsFor($language); +``` + +Loads all translations from an arbitrary path for one language. No global state is mutated — the temporary instance is discarded after the call. + +### `findMissingKeysFromPath($sourcePath, $targetPath, $language): array` + +Diffs two paths for a single language. Delegates to `compareTranslations()` internally and returns a `[type => [group => [key => value]]]` tree containing only keys present in `$sourcePath` but missing from `$targetPath`. + +### `findAllMissingKeys($sourcePath, $targetPath): array` + +Iterates every language present in `$sourcePath` (via a throwaway `FileTranslation` rooted there) and returns: + +```php +[ + 'en' => [ /* missing tree for en */ ], + 'tr' => [ /* missing tree for tr */ ], + // ... languages with no missing keys are omitted +] +``` + +### `syncMissingKeysToPath($sourcePath, $targetPath, $language, $missingKeys): void` + +Writes the provided missing-keys tree into `$targetPath`. Branches by type: + +- `type === 'single'` → `syncSingleTranslations()` (JSON) +- otherwise → `syncGroupTranslations()` (PHP file) + +### `syncAllMissingKeys($sourcePath, $targetPath): array` + +End-to-end sync for every language. Returns a stats array: + +```php +[ + 'languages' => [ + 'en' => 12, // 12 keys synced for English + 'tr' => 5, // 5 keys synced for Turkish + ], + 'total_keys' => 17, +] +``` + +This is the method called by `modularity:sync:translations`. + +## Internal Sync Helpers + +Protected methods invoked by `syncMissingKeysToPath()`: + +| Method | Role | +|--------|------| +| `compareTranslations(Collection $source, Collection $target): array` | Nested loop that builds the missing-keys tree — `$target->get($type)->get($group)->has($key)` misses become entries in `$missing` | +| `syncGroupTranslations($targetInstance, $language, $group, $translations)` | Loads existing target group translations, merges the new values, and calls `saveGroupTranslations()` | +| `syncSingleTranslations($targetInstance, $language, $group, $translations)` | Same flow for single (JSON) translations, writing via `saveSingleTranslationsToPath()` | +| `saveSingleTranslationsToPath($targetInstance, $language, $group, Collection $translations)` | Writes JSON to `{languageFilesPath}/vendor/{namespace}/{locale}.json` when the group is namespaced (`{namespace}::single`), else `{languageFilesPath}/{locale}.json`. Creates the directory (`0755`) when missing and uses `JSON_UNESCAPED_UNICODE \| JSON_PRETTY_PRINT` | + +## Save Paths + +### `saveGroupTranslations($language, $group, $translations): void` + +Persists a group translation file. Normalises input (`Collection` → array), sorts keys alphabetically (`ksort`), and expands dot-notation keys back into a nested array via `array_undot()`. + +Two write paths: + +| Group format | Output path | +|--------------|-------------| +| Plain (`messages`) | `{languageFilesPath}/{locale}/{group}.php` | +| Namespaced (`admin::users`) | `{languageFilesPath}/vendor/{namespace}/{locale}/{group}.php` (via `saveNamespacedGroupTranslations`) | + +Both branches use `php_array_file_content($translations)` to render the PHP array. + +### `saveNamespacedGroupTranslations($language, $group, $translations): void` _(private)_ + +Splits the namespaced group on `::`, ensures the vendor language directory exists, and writes the translation array to the resolved path with `php_array_file_content()`. + +## Translation File Layout + +| Type | Storage | Format | +|------|---------|--------| +| Group | `lang/{locale}/{group}.php` | PHP array | +| JSON (single) | `lang/{locale}.json` | JSON object | +| Namespaced group | `lang/vendor/{namespace}/{locale}/{group}.php` | PHP array | +| Namespaced JSON | `lang/vendor/{namespace}/{locale}.json` | JSON object (written by `saveSingleTranslationsToPath()` when the group is `{namespace}::single`) | + +## Related + +- [Translation](./translation) — abstract base class (scanning, source-locale merge, `add()`) +- [`modularity:sync:translations`](/guide/console/sync/sync-translations) — command that drives `syncAllMissingKeys()` +- [FileLoader](../support/file-loader) — multi-path translation loader that reads files written by this driver diff --git a/docs/src/pages/system-reference/backend/services/filepond-manager.md b/docs/src/pages/system-reference/backend/services/filepond-manager.md new file mode 100644 index 000000000..614d7f53a --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/filepond-manager.md @@ -0,0 +1,107 @@ +--- +sidebarPos: 14 +sidebarTitle: FilepondManager +--- + +# FilepondManager + +**File**: `src/Services/FilepondManager.php` +**Facade**: `Unusualify\Modularity\Facades\Filepond` + +Manages the full lifecycle of [FilePond](https://pqina.nl/filepond/) file uploads: accepting temporary uploads, serving previews, persisting files to permanent storage when a model is saved, and cleaning up orphaned or expired temporary files. + +## Storage Layout + +| Path | Purpose | +|------|---------| +| `public/fileponds/tmp/{uuid}/` | Temporary location — files land here on upload | +| `public/fileponds/{uuid}/` | Permanent location — files are moved here on model save | + +Each uploaded file is tracked in the `temporary_fileponts` table via a `TemporaryFilepond` model, using a UUID (`folder_name`) as the unique identifier. On persist, a `Filepond` record is created and the temp record is deleted. + +## Upload Flow + +``` +1. User selects file in VInputFilepond +2. Frontend POSTs to filepond upload route +3. createTemporaryFilepond() stores file in tmp/ and returns UUID +4. UUID is stored in form state +5. On form submit, saveFile() is called with submitted UUIDs +6. New UUIDs → persistFile() moves file from tmp/ to permanent path +7. Removed UUIDs → deleteFile() removes file and DB record +``` + +## Key Methods + +### Upload & Temp Management + +| Method | Description | +|--------|-------------| +| `createTemporaryFilepond(Request $request)` | Store an uploaded file in the temp path; returns the UUID string | +| `deleteTemporaryFilepond(Request $request)` | Delete a temp file by UUID (called when user removes a file before saving) | +| `clearTemporaryFiles($days = 7)` | Purge all temp uploads older than `$days` days (used by scheduler) | + +### Persistence + +| Method | Description | +|--------|-------------| +| `persistFile(TemporaryFilepond $temp, Model $model, $role, $locale)` | Move a temp file to permanent storage and create a `Filepond` DB record | +| `saveFile($object, $files, $role, $locale)` | Reconcile submitted UUID list against existing records — persists new files, deletes removed ones | +| `createFilepond($object, $temp, $role, $locale)` | Create a `Filepond` record after a file has been moved | +| `deleteFile($folder)` | Delete a permanent file from storage and its `Filepond` DB record | + +### File Access + +| Method | Description | +|--------|-------------| +| `previewFile($folder)` | Stream a file for inline preview — returns image response or file response depending on mime type | +| `getFileInfo($uuid)` | Return `['size', 'type', 'name']` for a UUID | +| `getStoragePath($uuid)` | Resolve the full storage path for a UUID (checks permanent first, then temp) | +| `getStorageFile($uuid)` | Return the first file path within the UUID folder | +| `getEncodedFile($folder)` | Return the file as a base64-encoded string | +| `clearFolders()` | Remove orphaned permanent folders not referenced by any `Filepond` record | + +## Session Tracking + +When a file is temporarily uploaded, its UUID is stored in the PHP session under `_filepond.{input_role}`. This allows the server to associate the temp file with the form field if the form is submitted without a full page reload. + +```php +// Session key pattern +"_filepond.{input_role}" => "uuid-folder-name" +``` + +## Scheduler Integration + +`FilepondsScheduler` calls `clearTemporaryFiles()` on a schedule to prevent the temp directory from accumulating stale uploads. Register it in your `Console/Kernel.php`: + +```php +$schedule->call(function () { + app(\Unusualify\Modularity\Services\FilepondManager::class)->clearTemporaryFiles(7); +})->daily(); +``` + +## Facade Usage + +```php +use Unusualify\Modularity\Facades\Filepond; + +// In a controller upload endpoint +public function upload(Request $request) +{ + return Filepond::createTemporaryFilepond($request); +} + +// In a controller delete endpoint +public function delete(Request $request) +{ + Filepond::deleteTemporaryFilepond($request); +} + +// In a repository after model save +Filepond::saveFile($model, $request->input('documents'), 'documents', $locale); +``` + +## See Also + +- [File Storage with FilePond](/guide/generics/file-storage-with-filepond) — guide-level walkthrough +- [VInputFilepond](/guide/form-inputs/input-filepond) — the Vue component diff --git a/docs/src/pages/system-reference/backend/services/media-library/abstract-params-processor.md b/docs/src/pages/system-reference/backend/services/media-library/abstract-params-processor.md new file mode 100644 index 000000000..f6e51ff75 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/abstract-params-processor.md @@ -0,0 +1,86 @@ +--- +sidebarPos: 8 +sidebarTitle: AbstractParamsProcessor +--- + +# AbstractParamsProcessor + +**File**: `src/Services/MediaLibrary/AbstractParamsProcessor.php` + +`AbstractParamsProcessor` is the base class for image transformation parameter translators. It provides a dispatch loop that routes each incoming parameter to a named handler method, then calls `finalizeParams()` to produce the driver-specific output. + +This class was introduced alongside the TwicPics driver to provide a compatibility layer for the minimum set of standard image parameters (`w`, `h`, `fm`, `q`, `fit`). + +## Compatible Parameters + +```php +const COMPATIBLE_PARAMS = [ + 'w' => 'width', + 'h' => 'height', + 'fm' => 'format', + 'q' => 'quality', + 'fit' => 'fit', +]; +``` + +Parameters in this map are extracted into typed properties. All other parameters remain in `$this->params` as-is. + +## Properties + +| Property | Type | Set from param | +|----------|------|---------------| +| `$width` | mixed | `w` | +| `$height` | mixed | `h` | +| `$format` | mixed | `fm` | +| `$quality` | mixed | `q` | +| `$fit` | mixed | `fit` | +| `$params` | array | Remaining / pass-through params | + +## Methods + +| Method | Description | +|--------|-------------| +| `process(array $params): array` | Entry point — dispatches each param to a handler, then calls `finalizeParams()` | +| `handleParam(string $key, mixed $value): void` | Default handler — maps `COMPATIBLE_PARAMS` keys to properties; leaves unknown keys in `$params` | +| `finalizeParams(): array` | **Abstract** — must be implemented by concrete processors; returns the final params array | + +## Custom Handler Convention + +Override parameter handling by defining a method named `handleParam{KEY}`: + +```php +// Override handling for the 'fit' parameter +protected function handleParamFit(string $key, mixed $value): void +{ + if ($value === 'crop') { + $this->cropFit = true; + unset($this->params[$key]); + } +} +``` + +If `handleParam{KEY}` exists, the generic `handleParam()` is skipped for that key. + +## Implementing a Custom Processor + +```php +class CloudflareParamsProcessor extends AbstractParamsProcessor +{ + public function finalizeParams(): array + { + $output = []; + + if ($this->width) $output['width'] = $this->width; + if ($this->height) $output['height'] = $this->height; + if ($this->quality) $output['quality'] = $this->quality; + + return array_merge($output, $this->params); + } +} +``` + +## Known Implementations + +| Class | Used by | +|-------|---------| +| [TwicPicsParamsProcessor](/system-reference/backend/services/media-library/twicpics-params-processor) | `TwicPics` driver | diff --git a/docs/src/pages/system-reference/backend/services/media-library/glide.md b/docs/src/pages/system-reference/backend/services/media-library/glide.md new file mode 100644 index 000000000..34e6bfc40 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/glide.md @@ -0,0 +1,93 @@ +--- +sidebarPos: 2 +sidebarTitle: Glide +--- + +# Glide + +**File**: `src/Services/MediaLibrary/Glide.php` +**Config value**: `glide` +**Package**: `league/glide` + +The `Glide` driver provides **server-side, on-the-fly image transformation** using [League Glide](https://glide.thephpleague.com/). Crop, resize, format conversion, and quality adjustments are applied by the Glide server on each request and served through a dedicated route. + +## Configuration + +```php +// config/modularity.php +'media_library' => [ + 'image_service' => 'glide', +], + +'glide' => [ + 'base_url' => null, // defaults to app.url + 'base_path' => 'img', // URL path prefix for the Glide endpoint + 'source' => storage_path('app/public'), + 'cache' => storage_path('app/glide_cache'), + 'cache_path_prefix' => '.cache', + 'use_signed_urls' => false, + 'sign_key' => env('GLIDE_SIGN_KEY'), + 'driver' => 'gd', // gd | imagick + 'presets' => [], + 'default_params' => ['q' => 90, 'fm' => 'webp'], + 'lqip_default_params'=> ['w' => 20, 'blur' => 1], + 'social_default_params' => ['w' => 1200, 'h' => 630, 'fit' => 'crop'], + 'cms_default_params' => ['w' => 240, 'h' => 180, 'fit' => 'crop'], + 'original_media_for_extensions' => ['svg', 'gif'], + 'add_params_to_svgs' => false, +], +``` + +## Signed URLs + +When `use_signed_urls = true`, the service signs every generated URL using the `sign_key`. The Glide render endpoint validates the signature and returns HTTP 403 if it is invalid. This prevents URL manipulation attacks. + +```php +'use_signed_urls' => true, +'sign_key' => env('GLIDE_SIGN_KEY', 'a-secure-random-string'), +``` + +## Render Endpoint + +Register the Glide render route so the server can handle transformation requests: + +```php +Route::get('/img/{path}', function ($path) { + return app(\Unusualify\Modularity\Services\MediaLibrary\Glide::class)->render($path); +})->where('path', '.*'); +``` + +`render($path)` validates the signature (if enabled) and streams the transformed image. + +## Presets + +Define reusable transformation presets in config: + +```php +'presets' => [ + 'thumbnail' => ['w' => 300, 'h' => 300, 'fit' => 'crop'], + 'banner' => ['w' => 1200, 'h' => 400, 'fit' => 'crop'], +], +``` + +Access a preset URL with `getPresetUrl($id, 'thumbnail')`. + +## URL Parameters + +Glide uses standard Glide/Intervention query parameters: + +| Param | Example | Effect | +|-------|---------|--------| +| `w` | `w=300` | Width in pixels | +| `h` | `h=200` | Height in pixels | +| `fit` | `fit=crop` | Resize mode: `crop`, `contain`, `fill`, etc. | +| `q` | `q=80` | Quality (1–100) | +| `fm` | `fm=webp` | Output format: `jpg`, `png`, `webp`, `gif` | +| `blur` | `blur=1` | Blur radius (used for LQIP) | +| `crop` | `crop=w,h,x,y` | Manual crop rectangle | + +## Crop & Focal Point + +`getUrlWithCrop()` accepts `crop_x`, `crop_y`, `crop_w`, `crop_h` and converts them to Glide's `crop` parameter format. + +`getUrlWithFocalCrop()` converts crop coordinates + original dimensions to Glide's `fit=crop-{fpX}-{fpY}-{fpZ}` focal-point syntax. diff --git a/docs/src/pages/system-reference/backend/services/media-library/image-service-defaults.md b/docs/src/pages/system-reference/backend/services/media-library/image-service-defaults.md new file mode 100644 index 000000000..12e057543 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/image-service-defaults.md @@ -0,0 +1,49 @@ +--- +sidebarPos: 7 +sidebarTitle: ImageServiceDefaults +--- + +# ImageServiceDefaults + +**File**: `src/Services/MediaLibrary/ImageServiceDefaults.php` + +`ImageServiceDefaults` is a trait included by image service drivers to provide shared default implementations for two `ImageServiceInterface` methods that behave identically across all drivers. + +## Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `getSocialFallbackUrl(): string` | Config-based URL or local fallback | Returns `ImageService::getSocialUrl($id)` for the SEO default image if `seo.image_default_id` is set; otherwise returns `seo.image_local_fallback` | +| `getTransparentFallbackUrl(): string` | Base64 GIF data URI | Returns `data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7` — a 1×1 transparent pixel | + +## Constants + +The trait also defines the `$cropParamsKeys` property used by all drivers when extracting crop coordinates from params arrays: + +```php +protected $cropParamsKeys = ['crop_x', 'crop_y', 'crop_w', 'crop_h']; +``` + +## Configuration + +```php +// config/modularity.php +'seo' => [ + 'image_default_id' => null, // UUID of the default social image + 'image_local_fallback' => '/img/og.jpg', // static fallback path +], +``` + +## Usage + +All built-in drivers (`Local`, `Glide`, `Imgix`, `TwicPics`) use this trait: + +```php +class Glide implements ImageServiceInterface +{ + use ImageServiceDefaults; + + // getSocialFallbackUrl() and getTransparentFallbackUrl() + // are provided by the trait — no need to implement them here +} +``` diff --git a/docs/src/pages/system-reference/backend/services/media-library/image-service-interface.md b/docs/src/pages/system-reference/backend/services/media-library/image-service-interface.md new file mode 100644 index 000000000..9c4a1e6df --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/image-service-interface.md @@ -0,0 +1,74 @@ +--- +sidebarPos: 5 +sidebarTitle: ImageServiceInterface +--- + +# ImageServiceInterface + +**File**: `src/Services/MediaLibrary/ImageServiceInterface.php` + +Contract that all MediaLibrary drivers must implement. Defines the full set of URL-generating methods that the rest of the framework calls, regardless of which image service is active. + +## Interface Definition + +```php +interface ImageServiceInterface +{ + public function getUrl($id, array $params = []); + public function getUrlWithCrop($id, array $crop_params, array $params = []); + public function getUrlWithFocalCrop($id, array $cropParams, $width, $height, array $params = []); + public function getLQIPUrl($id, array $params = []); + public function getSocialUrl($id, array $params = []); + public function getCmsUrl($id, array $params = []); + public function getRawUrl($id); + public function getDimensions($id); + public function getSocialFallbackUrl(); + public function getTransparentFallbackUrl(); +} +``` + +## Methods + +| Method | Description | +|--------|-------------| +| `getUrl($id, $params)` | Standard URL with optional transformation parameters | +| `getUrlWithCrop($id, $crop_params, $params)` | URL with a manual crop rectangle (`crop_x`, `crop_y`, `crop_w`, `crop_h`) | +| `getUrlWithFocalCrop($id, $cropParams, $width, $height, $params)` | URL with focal-point crop — preserves the subject during responsive resizing | +| `getLQIPUrl($id, $params)` | Low-Quality Image Placeholder URL (small, blurred preview) | +| `getSocialUrl($id, $params)` | URL optimised for social sharing (typically 1200×630) | +| `getCmsUrl($id, $params)` | URL sized for CMS thumbnails (typically 240×180) | +| `getRawUrl($id)` | Original file URL with no transformation parameters | +| `getDimensions($id)` | Returns `['width' => int, 'height' => int]` or `null` if unsupported | +| `getSocialFallbackUrl()` | Default social image URL when no media is attached | +| `getTransparentFallbackUrl()` | 1×1 transparent GIF data URI — safe placeholder | + +## Built-in Implementations + +| Driver | Config value | +|--------|-------------| +| [Local](/system-reference/backend/services/media-library/local) | `local` | +| [Glide](/system-reference/backend/services/media-library/glide) | `glide` | +| [Imgix](/system-reference/backend/services/media-library/imgix) | `imgix` | +| [TwicPics](/system-reference/backend/services/media-library/twicpics) | `twicpics` | + +## Implementing a Custom Driver + +```php +use Unusualify\Modularity\Services\MediaLibrary\ImageServiceInterface; + +class CloudflareImages implements ImageServiceInterface +{ + public function getUrl($id, array $params = []): string + { + return "https://imagedelivery.net/account/{$id}/public"; + } + + // ... implement remaining methods +} +``` + +Register in a service provider: + +```php +$this->app->bind('imageService', CloudflareImages::class); +``` diff --git a/docs/src/pages/system-reference/backend/services/media-library/image-service.md b/docs/src/pages/system-reference/backend/services/media-library/image-service.md new file mode 100644 index 000000000..016aefe00 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/image-service.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 6 +sidebarTitle: ImageService (Facade) +--- + +# ImageService + +**File**: `src/Services/MediaLibrary/ImageService.php` +**Extends**: `Illuminate\Support\Facades\Facade` +**Bound as**: `imageService` + +`ImageService` is the Laravel Facade that provides a static proxy to whichever `ImageServiceInterface` driver is bound in the service container. The active driver is determined by the `media_library.image_service` config value. + +## Usage + +```php +use Unusualify\Modularity\Services\MediaLibrary\ImageService; + +$url = ImageService::getUrl($media->uuid); +$lqip = ImageService::getLQIPUrl($media->uuid); +$crop = ImageService::getUrlWithCrop($media->uuid, [ + 'crop_x' => 0, + 'crop_y' => 0, + 'crop_w' => 800, + 'crop_h' => 600, +]); +``` + +All methods on `ImageServiceInterface` are accessible statically through this Facade. + +## Driver Selection + +```php +// config/modularity.php +'media_library' => [ + 'image_service' => env('IMAGE_SERVICE', 'local'), // local | glide | imgix | twicpics +], +``` + +| Value | Driver | +|-------|--------| +| `local` | [Local](/system-reference/backend/services/media-library/local) | +| `glide` | [Glide](/system-reference/backend/services/media-library/glide) | +| `imgix` | [Imgix](/system-reference/backend/services/media-library/imgix) | +| `twicpics` | [TwicPics](/system-reference/backend/services/media-library/twicpics) | + +## Swapping Drivers at Runtime + +```php +// Temporarily use Imgix in a specific context +$url = app('imageService')->getUrl($id); // uses configured driver + +// Or resolve directly from the container +$imgix = app(\Unusualify\Modularity\Services\MediaLibrary\Imgix::class); +$url = $imgix->getUrl($id); +``` diff --git a/docs/src/pages/system-reference/backend/services/media-library/imgix.md b/docs/src/pages/system-reference/backend/services/media-library/imgix.md new file mode 100644 index 000000000..0c8477c3c --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/imgix.md @@ -0,0 +1,68 @@ +--- +sidebarPos: 3 +sidebarTitle: Imgix +--- + +# Imgix + +**File**: `src/Services/MediaLibrary/Imgix.php` +**Config value**: `imgix` +**Package**: `imgix/imgix-php` + +The `Imgix` driver generates [Imgix](https://imgix.com/) CDN URLs with optional HMAC signing. Transformations are applied by the Imgix CDN on the first request and cached at the edge. + +## Configuration + +```php +// config/modularity.php +'imgix' => [ + 'source_host' => env('IMGIX_SOURCE_HOST', 'your-source.imgix.net'), + 'use_https' => true, + 'use_signed_urls' => false, + 'sign_key' => env('IMGIX_SIGN_KEY'), + 'default_params' => ['auto' => 'format,compress', 'q' => 80], + 'lqip_default_params' => ['w' => 20, 'blur' => 200, 'q' => 1], + 'social_default_params' => ['w' => 1200, 'h' => 630, 'fit' => 'crop'], + 'cms_default_params' => ['w' => 240, 'h' => 180, 'fit' => 'crop'], + 'add_params_to_svgs' => false, +], +``` + +## Signed URLs + +When `use_signed_urls = true`, every generated URL is HMAC-signed using the `sign_key`. Imgix validates the signature before serving the image, preventing URL manipulation. + +## Crop & Focal Point + +`getUrlWithCrop()` converts crop coordinates to Imgix's `rect` parameter: + +``` +rect=x,y,w,h +``` + +`getUrlWithFocalCrop()` converts coordinates to Imgix's focal point parameters: + +``` +fp-x=0.50&fp-y=0.30&fp-z=1.5&crop=focalpoint&fit=crop +``` + +## Dimensions + +`getDimensions()` fetches the image metadata by requesting the URL with `?fm=json`, then reads `PixelWidth` and `PixelHeight` from the JSON response. + +## SVG Handling + +When `add_params_to_svgs = false` (default), SVG files are served via `getRawUrl()` without any transformation parameters. + +## Common URL Parameters + +Imgix supports all standard [Imgix URL API parameters](https://docs.imgix.com/apis/url). Common ones: + +| Param | Example | Effect | +|-------|---------|--------| +| `w` | `w=300` | Width | +| `h` | `h=200` | Height | +| `fit` | `fit=crop` | Resize mode | +| `auto` | `auto=format` | Auto format selection (WebP where supported) | +| `q` | `q=80` | Quality | +| `rect` | `rect=0,0,300,200` | Crop rectangle | diff --git a/docs/src/pages/system-reference/backend/services/media-library/local.md b/docs/src/pages/system-reference/backend/services/media-library/local.md new file mode 100644 index 000000000..c15d94d1c --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/local.md @@ -0,0 +1,43 @@ +--- +sidebarPos: 1 +sidebarTitle: Local +--- + +# Local + +**File**: `src/Services/MediaLibrary/Local.php` +**Config value**: `local` + +The `Local` driver serves images directly from the configured Laravel storage disk via `Storage::url()`. It does **not** apply any transformations — all URL-generating methods return the raw storage URL regardless of the parameters passed. + +## When to Use + +- Local development environments +- Applications that don't need server-side image transformation +- When images are pre-processed before storage + +## Configuration + +```php +// config/modularity.php +'media_library' => [ + 'image_service' => 'local', + 'disk' => 'public', // any Laravel filesystem disk +], +``` + +## Behaviour + +All interface methods (`getUrl`, `getUrlWithCrop`, `getUrlWithFocalCrop`, `getLQIPUrl`, `getSocialUrl`, `getCmsUrl`) delegate to `getRawUrl()`, which returns: + +```php +Storage::disk(config('modularity.media_library.disk'))->url($id) +``` + +`getDimensions()` is not supported and returns `null`. + +## Notes + +- Since no transformation is applied, passing `$params` or `$cropParams` has no effect. +- LQIP and social URLs are identical to the full-resolution URL. +- Suitable as a fallback driver when no CDN is configured. diff --git a/docs/src/pages/system-reference/backend/services/media-library/overview.md b/docs/src/pages/system-reference/backend/services/media-library/overview.md new file mode 100644 index 000000000..c756911ab --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/overview.md @@ -0,0 +1,65 @@ +--- +sidebarPos: 5 +sidebarTitle: Overview +--- + +# MediaLibrary Services + +**Directory**: `src/Services/MediaLibrary/` +**Facade**: `Unusualify\Modularity\Facades\ImageService` (bound as `imageService`) + +The MediaLibrary namespace provides a **driver-based image service abstraction**. All concrete drivers implement `ImageServiceInterface`, exposing a unified API for generating image URLs regardless of the underlying CDN or storage backend. + +## Selecting a Driver + +Set `modularity.media_library.image_service` in `config/modularity.php`: + +```php +'media_library' => [ + 'image_service' => env('MEDIA_LIBRARY_IMAGE_SERVICE', 'local'), + 'disk' => env('MEDIA_LIBRARY_DISK', 'public'), +], +``` + +| Driver | Config value | Page | +|--------|-------------|------| +| [Local](/system-reference/backend/services/media-library/local) | `local` | Direct disk URL — no transformation | +| [Glide](/system-reference/backend/services/media-library/glide) | `glide` | On-the-fly server-side transformation via League/Glide | +| [Imgix](/system-reference/backend/services/media-library/imgix) | `imgix` | Imgix CDN with signed/unsigned URL generation | +| [TwicPics](/system-reference/backend/services/media-library/twicpics) | `twicpics` | TwicPics CDN with transformation string | + +## Supporting Classes + +| Class | Description | Page | +|-------|-------------|------| +| [ImageServiceInterface](/system-reference/backend/services/media-library/image-service-interface) | Contract all drivers must implement | [→](/system-reference/backend/services/media-library/image-service-interface) | +| [ImageService](/system-reference/backend/services/media-library/image-service) | Laravel Facade resolving to the active driver | [→](/system-reference/backend/services/media-library/image-service) | +| [ImageServiceDefaults](/system-reference/backend/services/media-library/image-service-defaults) | Shared trait: `getSocialFallbackUrl`, `getTransparentFallbackUrl` | [→](/system-reference/backend/services/media-library/image-service-defaults) | +| [AbstractParamsProcessor](/system-reference/backend/services/media-library/abstract-params-processor) | Base class for driver-specific parameter translators | [→](/system-reference/backend/services/media-library/abstract-params-processor) | +| [TwicPicsParamsProcessor](/system-reference/backend/services/media-library/twicpics-params-processor) | Translates standard params to TwicPics transformation syntax | [→](/system-reference/backend/services/media-library/twicpics-params-processor) | + +## ImageServiceInterface + +All drivers implement the following interface: + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getUrl` | `getUrl(string $id, array $params): string` | Standard URL, applying default transformation params | +| `getUrlWithCrop` | `getUrlWithCrop(string $id, array $cropParams, array $params): string` | URL with explicit crop coordinates (`crop_x`, `crop_y`, `crop_w`, `crop_h`) | +| `getUrlWithFocalCrop` | `getUrlWithFocalCrop(string $id, array $cropParams, int $width, int $height, array $params): string` | URL using focal-point crop calculated from crop coordinates and original dimensions | +| `getLQIPUrl` | `getLQIPUrl(string $id, array $params): string` | Low-quality image placeholder URL (small, blurry preview) | +| `getSocialUrl` | `getSocialUrl(string $id, array $params): string` | Open Graph / social sharing optimized URL | +| `getCmsUrl` | `getCmsUrl(string $id, array $params): string` | Admin panel thumbnail URL | +| `getRawUrl` | `getRawUrl(string $id): string` | Unmodified source URL without any transformation params | +| `getDimensions` | `getDimensions(string $id): ?array` | Return `['width' => int, 'height' => int]` or `null` if unsupported | + +## Facade Usage + +```php +use Unusualify\Modularity\Services\MediaLibrary\ImageService; + +// Via facade (resolves the active driver) +$url = ImageService::getUrl($media->uuid); +$lqip = ImageService::getLQIPUrl($media->uuid); +$cropped = ImageService::getUrlWithCrop($media->uuid, $media->crop_params); +``` diff --git a/docs/src/pages/system-reference/backend/services/media-library/twicpics-params-processor.md b/docs/src/pages/system-reference/backend/services/media-library/twicpics-params-processor.md new file mode 100644 index 000000000..8fae7c43a --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/twicpics-params-processor.md @@ -0,0 +1,55 @@ +--- +sidebarPos: 9 +sidebarTitle: TwicPicsParamsProcessor +--- + +# TwicPicsParamsProcessor + +**File**: `src/Services/MediaLibrary/TwicPicsParamsProcessor.php` +**Extends**: `AbstractParamsProcessor` + +`TwicPicsParamsProcessor` translates standard Modularous image params (`w`, `h`, `fm`, `q`, `fit`) into TwicPics transformation syntax. It is instantiated internally by the [TwicPics](/system-reference/backend/services/media-library/twicpics) driver. + +## Parameter Translation + +| Input param | TwicPics output param | Notes | +|-------------|----------------------|-------| +| `fm` | `output` | Format conversion | +| `q` | `quality` | Quality (1–100) | +| `w` + `h` (no crop) | `resize={w}x{h}` | Responsive resize; `-` used when only one dimension is set | +| `w` + `h` + `fit=crop` | `crop={w}x{h}` | Hard crop to exact dimensions | +| `fit=crop` alone | sets `$cropFit = true` | Signals that a `crop` directive should be used instead of `resize` | + +## `finalizeParams()` Logic + +1. If `$format` is set → `$params['output'] = $format` +2. If `$quality` is set → `$params['quality'] = $quality` +3. If `$width` or `$height` is set: + - Missing dimension is replaced with `-` (TwicPics wildcard) + - If `$cropFit` is `true` → `$params['crop'] = "{w}x{h}"` + - Otherwise → `$params['resize'] = "{w}x{h}"` +4. Returns merged `$params` + +## `handleParamFit` Override + +```php +protected function handleParamFit($key, $value) +{ + if ($value !== 'crop') return; // ignore non-crop fit values + if (isset($this->params['crop'])) return; // don't override explicit crop + + $this->cropFit = true; + unset($this->params[$key]); // consume the 'fit' key +} +``` + +## Example Transformations + +| Input | Output | +|-------|--------| +| `['w' => 300, 'h' => 200]` | `resize=300x200` | +| `['w' => 300, 'h' => 200, 'fit' => 'crop']` | `crop=300x200` | +| `['w' => 300]` | `resize=300x-` | +| `['fm' => 'webp', 'q' => 80]` | `output=webp&quality=80` | + +The resulting params array is appended to the TwicPics URL as `?twic=v1/{key}={value}/{key}={value}`. diff --git a/docs/src/pages/system-reference/backend/services/media-library/twicpics.md b/docs/src/pages/system-reference/backend/services/media-library/twicpics.md new file mode 100644 index 000000000..50f58c14c --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/media-library/twicpics.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 4 +sidebarTitle: TwicPics +--- + +# TwicPics + +**File**: `src/Services/MediaLibrary/TwicPics.php` +**Params Processor**: `src/Services/MediaLibrary/TwicPicsParamsProcessor.php` +**Config value**: `twicpics` + +The `TwicPics` driver generates [TwicPics](https://www.twicpics.com/) CDN URLs. Transformation parameters are built by `TwicPicsParamsProcessor` and appended to the CDN URL path using TwicPics' transformation string syntax. + +## Configuration + +```php +// config/modularity.php +'twicpics' => [ + 'base_url' => env('TWICPICS_BASE_URL', 'https://your-domain.twic.pics'), + 'default_params' => [], + 'lqip_default_params' => ['output' => 'preview'], + 'social_default_params' => ['resize' => '1200x630'], + 'cms_default_params' => ['resize' => '240x180'], +], +``` + +## TwicPicsParamsProcessor + +`TwicPicsParamsProcessor` extends `AbstractParamsProcessor` and translates the standard Modularous crop/resize param arrays into TwicPics' transformation string format. + +TwicPics uses a path-based transformation syntax: + +``` +https://your-domain.twic.pics/path/to/image.jpg?twic=v1/resize=300x200 +``` + +The processor builds the `?twic=v1/...` portion from the provided params. + +## Crop & Focal Point + +TwicPics supports crop via its `focus` and `crop` transformations. `getUrlWithCrop()` and `getUrlWithFocalCrop()` pass the crop coordinates through the params processor to generate the correct transformation string. + +## LQIP + +`getLQIPUrl()` uses `lqip_default_params` (default: `['output' => 'preview']`), which instructs TwicPics to return a low-resolution preview version of the image. diff --git a/docs/src/pages/system-reference/backend/services/message-stage.md b/docs/src/pages/system-reference/backend/services/message-stage.md new file mode 100644 index 000000000..2a4727751 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/message-stage.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 16 +sidebarTitle: MessageStage +--- + +# MessageStage + +**File**: `src/Services/MessageStage.php` + +A PHP 8.1 **backed enum** providing a type-safe vocabulary for flash messages, alert payloads, and notification status values across the application. + +## Definition + +```php +enum MessageStage: string +{ + case SUCCESS = 'success'; + case ERROR = 'error'; + case WARNING = 'warning'; + case INFO = 'info'; +} +``` + +## Cases + +| Case | Value | Typical Use | +|------|-------|-------------| +| `SUCCESS` | `'success'` | Operation completed successfully | +| `ERROR` | `'error'` | Operation failed | +| `WARNING` | `'warning'` | Non-fatal issue or cautionary state | +| `INFO` | `'info'` | Informational message | + +## Usage + +```php +use Unusualify\Modularity\Services\MessageStage; + +// In a controller — flash a typed message +session()->flash('message', [ + 'stage' => MessageStage::SUCCESS->value, + 'content' => __('Record saved successfully.'), +]); + +// In Inertia shared data +Inertia::share('flash', [ + 'stage' => MessageStage::ERROR->value, + 'content' => $errorMessage, +]); + +// In a match expression +$stage = MessageStage::from($request->input('stage')); +$cssClass = match ($stage) { + MessageStage::SUCCESS => 'alert-success', + MessageStage::ERROR => 'alert-danger', + MessageStage::WARNING => 'alert-warning', + MessageStage::INFO => 'alert-info', +}; +``` + +## Frontend Integration + +The `useAlert` Vue hook reads the `stage` value (the string `'success'`, `'error'`, etc.) from Inertia flash data and maps it to the corresponding Vuetify alert variant. diff --git a/docs/src/pages/system-reference/backend/services/migration-backup.md b/docs/src/pages/system-reference/backend/services/migration-backup.md new file mode 100644 index 000000000..f26ac1df6 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/migration-backup.md @@ -0,0 +1,92 @@ +--- +sidebarPos: 17 +sidebarTitle: MigrationBackup +--- + +# MigrationBackup + +**File**: `src/Services/MigrationBackup.php` +**Facade**: `Unusualify\Modularity\Facades\MigrationBackup` + +Provides data-safe migration helpers that snapshot table data and schema to the Laravel cache before running destructive migrations, then restore rows if a rollback is needed. + +Supports **MySQL**, **PostgreSQL**, and **SQLite**. All cache keys are namespaced by the calling migration's filename, so concurrent migrations never collide. + +## How It Works + +1. **Before migration** — call `backup()`. The service snapshots the table's current rows and schema (column types, nullability, defaults, foreign keys) to the Laravel cache. +2. **Run migration** — execute your `Schema::` changes normally. +3. **On rollback** — call `restore()`. The service re-inserts backed-up rows, adapting them to the current schema (new columns get type-appropriate defaults; removed columns are silently dropped). +4. **After rollback** — call `clearBackup()` to remove the cache entries. + +## Key Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `backup` | `backup(string $table, ?array $columns, bool $includeRelated): void` | Snapshot a table to cache. When `$includeRelated` is `true` (default), FK-referenced tables are also snapshotted. | +| `restore` | `restore(?string $table): bool` | Restore rows from the snapshot. Pass `null` to restore every table backed up by this migration. Returns `true` on success. | +| `clearBackup` | `clearBackup(?string $table): void` | Remove cached snapshots. Pass `null` to clear all snapshots for this migration. | +| `getBackup` | `getBackup(?string $table): ?array` | Inspect the raw snapshot data stored in cache. | +| `getSchemaHistory` | `getSchemaHistory(?string $table): array` | Return a log of column additions/removals/modifications detected during restore. | + +## Usage in Migrations + +```php +use Unusualify\Modularity\Services\MigrationBackup; + +class AddSkuToProductsTable extends Migration +{ + public function up(): void + { + $backup = app(MigrationBackup::class); + $backup->backup('products'); + + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('legacy_code'); + $table->string('sku')->unique(); + }); + } + + public function down(): void + { + $backup = app(MigrationBackup::class); + $backup->restore('products'); + $backup->clearBackup('products'); + } +} +``` + +## Foreign Key Handling + +During restore, foreign key constraints are temporarily disabled: + +| Driver | Statement | +|--------|-----------| +| MySQL | `SET FOREIGN_KEY_CHECKS=0` | +| PostgreSQL | `SET CONSTRAINTS ALL DEFERRED` | +| SQLite | `PRAGMA foreign_keys=OFF` | + +Constraints are re-enabled in a `finally` block, even if restore fails. + +## Schema Adaptation + +When restoring rows after a schema change, the service: + +- **Removes** columns that no longer exist from the row data before inserting +- **Fills** new columns with a type-appropriate default: + +| Column type | Default | +|-------------|---------| +| `varchar`, `text`, `char` | `''` | +| `int`, `bigint`, `smallint` | `0` | +| `decimal`, `float`, `double` | `0.0` | +| `boolean`, `tinyint(1)` | `false` | +| `datetime`, `timestamp`, `date`, `json` | `null` | + +## Cache Key Format + +``` +migration_backup_{migration-file-slug}_{table-slug} +``` + +All backup keys for a migration are tracked under `migration_backups_{migration-file-slug}`, which is also cleared by `clearBackup()`. diff --git a/docs/src/pages/system-reference/backend/services/modularity-cache-service.md b/docs/src/pages/system-reference/backend/services/modularity-cache-service.md new file mode 100644 index 000000000..8bd028cce --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/modularity-cache-service.md @@ -0,0 +1,124 @@ +--- +sidebarPos: 18 +sidebarTitle: ModularityCacheService +--- + +# ModularityCacheService + +**File**: `src/Services/ModularityCacheService.php` +**Facade**: `Unusualify\Modularity\Facades\ModularityCache` + +A tag-aware cache service that wraps Laravel's cache system with module-scoped key generation, per-module/route/type TTL configuration, and automatic Redis/Memcached connection validation at boot time. + +## Configuration + +All options live under `config/modularity.php` → `cache`: + +```php +'cache' => [ + 'enabled' => true, + 'driver' => 'redis', // redis | memcached | file | array + 'prefix' => 'modularity', + 'use_tags' => true, // disable if your driver doesn't support tags + 'all_modules' => false, // set to true to cache all modules by default + + // Global TTLs (seconds) + 'ttl' => [ + 'index' => 300, + 'show' => 600, + 'options' => 300, + ], + + // Per-module overrides + 'modules' => [ + 'Product' => [ + 'enabled' => true, + 'ttl' => ['index' => 60, 'show' => 120], + ], + 'Product.product' => [ // per-route key: ModuleName.routeName + 'enabled' => true, + 'ttl' => ['index' => 30], + ], + ], +], +``` + +## Cache Key Format + +Generated keys follow this pattern: + +``` +{prefix}:{ModuleName}:{RouteName}:{type}:{params_hash} +``` + +**Example**: `modularity:Product:Product:index:d41d8cd9` + +- `prefix` — from `cache.prefix` (default `modularity`) +- `ModuleName` / `RouteName` — converted to StudlyCase +- `type` — `index`, `show`, `options`, or any custom string +- `params_hash` — MD5 of sorted, serialized query parameters; `default` if no params + +## Key Methods + +### Checking & Configuration + +| Method | Signature | Description | +|--------|-----------|-------------| +| `isEnabled` | `isEnabled(?string $module, ?string $route, ?string $type): bool` | Check whether caching is active for a given context. Returns `false` if driver is disconnected. | +| `usesTags` | `usesTags(): bool` | Returns `true` when tags are configured and the driver supports them | +| `getDriver` | `getDriver(): string` | Return the configured driver name | +| `getPrefix` | `getPrefix(): string` | Return the cache key prefix | +| `getConfig` | `getConfig(): array` | Return the full cache config array | + +### Key Generation + +| Method | Signature | Description | +|--------|-----------|-------------| +| `generateCacheKey` | `generateCacheKey(string $module, string $route, string $type, array $params): string` | Build a deterministic cache key for the given context | + +### TTL Resolution + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getTtl` | `getTtl(string $type, ?string $module, ?string $route): int` | Resolve the effective TTL, checking route-level → module-level → global in that order | + +### Diagnostics + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getStats` | `getStats(?string $module): array` | Return `keys_count`, `keys[]`, and `using_tags` for a module (Redis only) | +| `getStore` | `getStore(): Repository` | Return the underlying `Illuminate\Cache\Repository` instance | + +## Tag Behaviour + +On boot, the service runs a small verification test to confirm that cache tags actually work (write → read → flush → confirm flush). If the flush does not clear the value, `use_tags` is automatically disabled in memory even if it is `true` in config. This prevents silent cache invalidation failures with misconfigured Redis instances. + +Supported tag drivers: Redis (via `predis` or `phpredis`), Memcached. +Unsupported: `file`, `database`, `array` — tags are silently disabled for these. + +## Repository Integration + +`CacheableTrait` in `src/Repositories/Logic/` calls these methods automatically on every `index()` and `show()` repository call when the module has caching enabled: + +```php +// Simplified internal flow in CacheableTrait +if ($this->cacheService->isEnabled($moduleName, $routeName, 'index')) { + $key = $this->cacheService->generateCacheKey($moduleName, $routeName, 'index', $params); + $ttl = $this->cacheService->getTtl('index', $moduleName, $routeName); + + return Cache::remember($key, $ttl, fn () => $this->fetchIndex($params)); +} +``` + +## Artisan Commands + +The cache system has six dedicated commands: + +| Command | Description | +|---------|-------------| +| [`modularity:cache:clear`](/guide/console/cache/cache-clear) | Clear all Modularity cache entries | +| [`modularity:cache:list`](/guide/console/cache/cache-list) | List cached keys and their TTLs | +| [`modularity:cache:warm`](/guide/console/cache/cache-warm) | Pre-warm the cache for all enabled modules | +| [`modularity:cache:stats`](/guide/console/cache/cache-stats) | Show cache statistics per module | +| [`modularity:cache:versions`](/guide/console/cache/cache-versions) | Show cache version history | +| [`modularity:cache:graph`](/guide/console/cache/cache-graph) | Visualize the cache relationship graph | diff --git a/docs/src/pages/system-reference/backend/services/overview.md b/docs/src/pages/system-reference/backend/services/overview.md new file mode 100644 index 000000000..d0c195b05 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/overview.md @@ -0,0 +1,27 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Services + +The `src/Services/` directory contains the top-level service classes that power Modularous internals. All services are bound in the Laravel service container and can be resolved via dependency injection or their dedicated Facades. + +## Service Reference + +| Service | Facade | Description | +|---------|--------|-------------| +| [Connector](/system-reference/backend/services/connector) | — | Parses connector strings in input configs into data source calls | +| [FilepondManager](/system-reference/backend/services/filepond-manager) | `Filepond` | Manages the full FilePond upload lifecycle | +| [ModularityCacheService](/system-reference/backend/services/modularity-cache-service) | `ModularityCache` | Tag-aware, module-scoped cache layer | +| [RedirectService](/system-reference/backend/services/redirect-service) | `Redirect` | Stores and retrieves post-auth redirect URLs | +| [BroadcastManager](/system-reference/backend/services/broadcast-manager) | — | Extracts WebSocket channel config from event classes | +| [MigrationBackup](/system-reference/backend/services/migration-backup) | `MigrationBackup` | Snapshots table data before destructive migrations | +| [Translation](/system-reference/backend/services/translation) | — | Abstract base driver — scanning, missing-key discovery, source-locale merge | +| [FileTranslation](/system-reference/backend/services/file-translation) | — | File-based driver with cross-path sync (package ↔ app lang files) | +| [MessageStage](/system-reference/backend/services/message-stage) | — | Backed enum for flash message status values | +| [UtmParameters](/system-reference/backend/services/utm-parameters) | `Utm` | Captures and persists UTM tracking parameters | +| [Assets](/system-reference/backend/services/assets) | — | Resolves frontend asset URLs (dev server / manifest) | +| [CurrencyExchangeService](/system-reference/backend/services/currency-exchange-service) | `CurrencyExchange` | Fetches and caches live exchange rates | +| [CacheRelationshipGraph](/system-reference/backend/services/cache-relationship-graph) | — | Builds a model→module-route dependency graph for targeted cache invalidation | +| [CoverageService](/system-reference/backend/services/coverage-service) | `coverage.service` | Parses Clover XML reports; generates coverage reports and PR checks | diff --git a/docs/src/pages/system-reference/backend/services/redirect-service.md b/docs/src/pages/system-reference/backend/services/redirect-service.md new file mode 100644 index 000000000..cee721a5a --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/redirect-service.md @@ -0,0 +1,62 @@ +--- +sidebarPos: 19 +sidebarTitle: RedirectService +--- + +# RedirectService + +**File**: `src/Services/RedirectService.php` +**Facade**: `Unusualify\Modularity\Facades\Redirect` + +A minimal service for storing and retrieving a **post-authentication redirect URL** across requests. Supports both session and cache storage so the intended destination survives a redirect to the login page. + +## How It Works + +When a guest visits a protected route, the URL is saved via `set()`. After successful login, `pull()` retrieves the URL and immediately clears it, then the controller redirects the user to their original destination. + +## Key Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `set` | `set(string $url, ?int $ttlSeconds, bool $useCache): void` | Store the redirect URL. Uses session by default; pass `$useCache = true` to store in cache instead (default TTL 600 s). | +| `get` | `get(): ?string` | Retrieve the stored URL. Checks session first, then cache. Returns `null` if nothing is stored. | +| `pull` | `pull(): ?string` | Retrieve the URL and immediately clear it from both session and cache. The primary method for post-login redirects. | +| `clear` | `clear(): void` | Remove the URL from both session and cache without returning it. | + +## Session & Cache Keys + +Both storage modes use the same key: `modularity.redirect_url`. + +## Typical Usage + +```php +use Unusualify\Modularity\Facades\Redirect; + +// In a middleware — save the intended URL before redirecting to login +public function handle($request, Closure $next) +{ + if (!auth()->check()) { + Redirect::set($request->url()); + return redirect()->route('login'); + } + return $next($request); +} + +// In the login controller — redirect to intended URL after authentication +public function authenticated(Request $request, $user) +{ + return redirect(Redirect::pull() ?? route('dashboard')); +} +``` + +## Session vs Cache Storage + +| Mode | Use when | +|------|----------| +| Session (default) | Standard web authentication — session persists across the login redirect | +| Cache | API-based or stateless flows where the session may not carry over | + +```php +// Cache mode with custom TTL +Redirect::set($url, ttlSeconds: 300, useCache: true); +``` diff --git a/docs/src/pages/system-reference/backend/services/translation.md b/docs/src/pages/system-reference/backend/services/translation.md new file mode 100644 index 000000000..0f1d9edaa --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/translation.md @@ -0,0 +1,83 @@ +--- +sidebarPos: 20 +sidebarTitle: Translation +--- + +# Translation + +**File**: `src/Services/Translation.php` +**Namespace**: `JoeDixon\Translation\Drivers` + +Abstract base class for every translation driver in the `joedixon/laravel-translation` package. Modularous ships a local copy of this file so it can fine-tune the shared driver contract (scanning, missing-key discovery, source-language merge) without forking the upstream package. Concrete drivers — most importantly [`FileTranslation`](./file-translation) — extend this class and implement the storage backend. + +The file lives under Modularous `src/Services/` tree but declares the upstream namespace so Laravel's autoloader picks it up in place of the vendored version. Only the contract below is Modularous-owned; downstream drivers define `$scanner`, `allTranslationsFor()`, `allLanguages()`, `addGroupTranslation()`, and `addSingleTranslation()`. + +## Class Signature + +```php +abstract class Translation +{ + public function findMissingTranslations($language); + public function saveMissingTranslations($language = false); + public function getSourceLanguageTranslationsWith($language); + public function filterTranslationsFor($language, $filter); + public function add(Request $request, $language, $isGroupTranslation); +} +``` + +## Methods + +### `findMissingTranslations($language): array` + +Returns every translation key discovered by `$this->scanner->findTranslations()` that has no value in `$language`. Uses `array_diff_assoc_recursive()` to compare the codebase-scanned tree against the persisted tree for the given locale. + +### `saveMissingTranslations($language = false): void` + +Walks one locale (when `$language` is truthy) or every locale returned by `allLanguages()` and persists blank entries for every missing key. Branches on the group name: + +- Groups containing the substring `single` → `addSingleTranslation($language, $group, $key)` (JSON file) +- Everything else → `addGroupTranslation($language, $group, $key)` (PHP array file) + +Used by translation-sync tooling to backfill missing keys before syncing values. + +### `getSourceLanguageTranslationsWith($language): Collection` + +Merges the source-locale tree (`app()->config['app']['locale']`) with the `$language` tree, producing a nested `Collection<type, Collection<group, array<key, [sourceLocale, $language]>>>`. The resulting shape is designed for side-by-side diff UIs — each key maps to `[source => value, target => value]`. + +### `filterTranslationsFor($language, $filter): Collection` + +Takes the merged collection from `getSourceLanguageTranslationsWith()` and filters entries whose **group name**, **key**, **source-locale value**, or **target-locale value** contains `$filter`. Groups with no surviving keys are dropped. Returns the full merged collection unchanged when `$filter` is empty. + +### `add(Request $request, $language, $isGroupTranslation): void` + +Adds a single translation entry from an HTTP request: + +```php +$namespace = $request->has('namespace') && $request->get('namespace') ? "{$request->get('namespace')}::" : ''; +$group = $namespace . $request->get('group'); +$key = $request->get('key'); +$value = $request->get('value') ?: ''; +``` + +- When `$isGroupTranslation` → `addGroupTranslation($language, $group, $key, $value)` +- Otherwise → `addSingleTranslation($language, 'single', $key, $value)` + +Dispatches a `JoeDixon\Translation\Events\TranslationAdded` event afterwards (`$group ?: 'single'` as the group label). Listeners can react by warming caches, regenerating front-end bundles, or broadcasting to other locales. + +## Abstract Hooks (implemented by drivers) + +The base class calls these but does not define them — consult the concrete driver for semantics: + +| Hook | Purpose | +|------|---------| +| `allTranslationsFor($language): Collection` | Load the persisted tree for one locale | +| `allLanguages(): Collection` | List every locale with at least one translation file | +| `addGroupTranslation($language, $group, $key, $value = '')` | Persist a key in a PHP group file | +| `addSingleTranslation($language, $group, $key, $value = '')` | Persist a key in a JSON single-file translation | +| `$scanner` | Object providing `findTranslations(): array` — walks source files looking for `__()`/`trans()`/`@lang()` calls | + +## Related + +- [FileTranslation](./file-translation) — the file-based driver that extends this class and adds cross-path sync +- [`modularity:sync:translations`](/guide/console/sync/sync-translations) — Artisan command that drives the sync flow +- [FileLoader](../support/file-loader) — multi-path Laravel translation loader that cooperates with `FileTranslation` diff --git a/docs/src/pages/system-reference/backend/services/uploader/overview.md b/docs/src/pages/system-reference/backend/services/uploader/overview.md new file mode 100644 index 000000000..c65d4a348 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/uploader/overview.md @@ -0,0 +1,52 @@ +--- +sidebarPos: 6 +sidebarTitle: Overview +--- + +# Uploader Services + +**Directory**: `src/Services/Uploader/` + +The Uploader namespace provides **direct-to-cloud upload signing** for S3 and Azure Blob Storage. Rather than routing file bytes through the Laravel server, the browser receives a signed policy or SAS URL and uploads directly to the cloud provider. + +## Classes + +| Class | Description | Page | +|-------|-------------|------| +| [SignUploadListener](/system-reference/backend/services/uploader/sign-upload-listener) | Callback interface for upload signing results | [→](/system-reference/backend/services/uploader/sign-upload-listener) | +| [SignS3Upload](/system-reference/backend/services/uploader/sign-s3-upload) | Signs AWS S3 browser-direct upload policies (AWS Signature V4) | [→](/system-reference/backend/services/uploader/sign-s3-upload) | +| [SignAzureUpload](/system-reference/backend/services/uploader/sign-azure-upload) | Generates Azure Blob SAS URLs for browser-direct uploads | [→](/system-reference/backend/services/uploader/sign-azure-upload) | + +## Flow + +``` +Browser Laravel Server Cloud + | | | + |-- POST /sign-upload ----→ | | + | [SignS3Upload or SignAzureUpload] | + | validates & signs policy | + |← signed policy / SAS URL | | + | | | + |-- PUT file directly -----------------------------------→ | +``` + +The Laravel endpoint is lightweight: it validates the policy and returns a signature. The actual file transfer bypasses the server entirely. + +## Disk Configuration + +Both signers read credentials from the Laravel filesystem disk config: + +```php +// config/filesystems.php +'disks' => [ + 'libraries' => [ + 'driver' => 's3', + 'bucket' => env('AWS_BUCKET'), + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + ], +], +``` + +Pass the disk name (default `'libraries'`) as the `$disk` parameter to the signing methods. diff --git a/docs/src/pages/system-reference/backend/services/uploader/sign-azure-upload.md b/docs/src/pages/system-reference/backend/services/uploader/sign-azure-upload.md new file mode 100644 index 000000000..55e278bcb --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/uploader/sign-azure-upload.md @@ -0,0 +1,85 @@ +--- +sidebarPos: 3 +sidebarTitle: SignAzureUpload +--- + +# SignAzureUpload + +**File**: `src/Services/Uploader/SignAzureUpload.php` +**Requires**: `microsoft/azure-storage-blob` + +`SignAzureUpload` generates an Azure Blob Storage **Shared Access Signature (SAS) URL** that authorises the browser to PUT or DELETE a blob directly, without routing file bytes through the Laravel server. + +## Method + +```php +public function getSasUrl(Request $request, SignUploadListener $listener, string $disk = 'libraries'): mixed +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$request` | `Request` | Must contain `bloburi` (full blob URL) and `_method` (`PUT` or `DELETE`) | +| `$listener` | `SignUploadListener` | Callback for success/failure | +| `$disk` | `string` | Laravel filesystem disk name (default `'libraries'`) | + +## Request Inputs + +| Input | Value | +|-------|-------| +| `bloburi` | Full Azure blob URL, e.g. `https://account.blob.core.windows.net/container/path/file.pdf` | +| `_method` | `PUT` (upload) or `DELETE` (remove) | + +## Signing Flow + +1. Reads `bloburi` and `_method` from the request. +2. Maps `PUT` → permission `'w'` (write), `DELETE` → permission `'d'` (delete). +3. Builds a `BlobSharedAccessSignatureHelper` using `name` and `key` from the disk config. +4. Sets expiry to `now + 15 minutes` (UTC). +5. Constructs the blob path by stripping the Azure endpoint prefix from `bloburi`. +6. Appends the SAS token to `bloburi` and returns it via `$listener->uploadIsSigned($sasUrl, false)`. + +## Disk Configuration + +```php +// config/filesystems.php +'disks' => [ + 'libraries' => [ + 'driver' => 'azure', + 'name' => env('AZURE_STORAGE_ACCOUNT'), + 'key' => env('AZURE_STORAGE_KEY'), + 'container' => env('AZURE_STORAGE_CONTAINER'), + ], +], +``` + +## Example Controller + +```php +use Unusualify\Modularity\Services\Uploader\SignAzureUpload; +use Unusualify\Modularity\Services\Uploader\SignUploadListener; + +class MediaController extends Controller implements SignUploadListener +{ + public function signAzure(Request $request, SignAzureUpload $signer) + { + return $signer->getSasUrl($request, $this); + } + + public function uploadIsSigned($signature, $isJsonResponse = true) + { + // $signature is the full SAS URL string + return response()->json(['sas_url' => $signature]); + } + + public function uploadIsNotValid() + { + return response()->json(['error' => 'Could not generate SAS URL'], 422); + } +} +``` + +## Notes + +- SAS tokens expire after 15 minutes. Generate them as close to the upload as possible. +- Any exception during SAS generation (wrong credentials, network issues) is caught and routed to `uploadIsNotValid()`. +- `$isJsonResponse` is passed as `false` because the return value is already a complete URL string, not a structured object. diff --git a/docs/src/pages/system-reference/backend/services/uploader/sign-s3-upload.md b/docs/src/pages/system-reference/backend/services/uploader/sign-s3-upload.md new file mode 100644 index 000000000..d77c3f168 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/uploader/sign-s3-upload.md @@ -0,0 +1,72 @@ +--- +sidebarPos: 2 +sidebarTitle: SignS3Upload +--- + +# SignS3Upload + +**File**: `src/Services/Uploader/SignS3Upload.php` + +`SignS3Upload` validates and signs an AWS S3 browser-direct upload policy using **AWS Signature Version 4**. The browser constructs a policy document and POSTs it to the Laravel endpoint; this service verifies the bucket and size conditions, then returns a Base64-encoded policy and HMAC-SHA256 signature. + +## Method + +```php +public function fromPolicy(string $policy, SignUploadListener $listener, string $disk = 'libraries'): mixed +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$policy` | `string` | JSON policy document from the browser | +| `$listener` | `SignUploadListener` | Callback interface for success/failure | +| `$disk` | `string` | Laravel filesystem disk name (default `'libraries'`) | + +Reads `bucket` and `secret` from `filesystems.disks.{$disk}`. + +## Signing Flow + +1. Decodes the JSON policy document. +2. Validates that `bucket` matches the configured disk bucket and `content-length-range` matches the expected max size. +3. Base64-encodes the policy JSON → `policy`. +4. Derives the AWS V4 signing key from the credential condition (`x-amz-credential`): `HMAC(HMAC(HMAC(HMAC('AWS4'+secret, date), region), 's3'), 'aws4_request')`. +5. Signs the encoded policy with the derived key → `signature`. +6. Returns `['policy' => $encodedPolicy, 'signature' => $hexSignature]` via `$listener->uploadIsSigned()`. + +## Response Structure + +```json +{ + "policy": "eyJleHBpcmF0aW9uIjoiMjAyNi0wMS0wMVQwMDowMDowMFoiLCJjb25kaXRpb25zIjpbXX0=", + "signature": "a3f9e2b1c4d5..." +} +``` + +## Example Controller + +```php +use Unusualify\Modularity\Services\Uploader\SignS3Upload; +use Unusualify\Modularity\Services\Uploader\SignUploadListener; + +class MediaController extends Controller implements SignUploadListener +{ + public function signS3(Request $request, SignS3Upload $signer) + { + return $signer->fromPolicy($request->input('policy'), $this); + } + + public function uploadIsSigned($signature, $isJsonResponse = true) + { + return response()->json($signature); + } + + public function uploadIsNotValid() + { + return response()->json(['error' => 'Invalid policy'], 422); + } +} +``` + +## Notes + +- The `expectedMaxSize` check in `isValid()` compares against `null` by default; override the method in a subclass to enforce a specific file size limit. +- Requires the disk's `secret` to be present in the filesystem disk config — not the global `AWS_SECRET_ACCESS_KEY` env variable unless the disk maps to it. diff --git a/docs/src/pages/system-reference/backend/services/uploader/sign-upload-listener.md b/docs/src/pages/system-reference/backend/services/uploader/sign-upload-listener.md new file mode 100644 index 000000000..5e1ce5d78 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/uploader/sign-upload-listener.md @@ -0,0 +1,55 @@ +--- +sidebarPos: 1 +sidebarTitle: SignUploadListener +--- + +# SignUploadListener + +**File**: `src/Services/Uploader/SignUploadListener.php` + +`SignUploadListener` is a callback interface implemented by the controller (or any class) that calls the upload signing services. It decouples the signing logic from the HTTP response format. + +## Interface Definition + +```php +interface SignUploadListener +{ + public function uploadIsSigned($signature, $isJsonResponse = true); + + public function uploadIsNotValid(); +} +``` + +## Methods + +| Method | Parameters | Called when | +|--------|-----------|-------------| +| `uploadIsSigned` | `$signature` — signed policy array (S3) or SAS URL string (Azure); `$isJsonResponse` — whether to return JSON (default `true`) | Signing succeeded | +| `uploadIsNotValid` | — | Policy is invalid or signing failed | + +## Implementing in a Controller + +```php +use Unusualify\Modularity\Services\Uploader\SignUploadListener; + +class FileUploadController extends Controller implements SignUploadListener +{ + public function sign(Request $request, SignS3Upload $signer) + { + return $signer->fromPolicy($request->input('policy'), $this); + } + + public function uploadIsSigned($signature, $isJsonResponse = true) + { + if ($isJsonResponse) { + return response()->json($signature); + } + return $signature; + } + + public function uploadIsNotValid() + { + return response()->json(['error' => 'Invalid upload policy'], 422); + } +} +``` diff --git a/docs/src/pages/system-reference/backend/services/utm-parameters.md b/docs/src/pages/system-reference/backend/services/utm-parameters.md new file mode 100644 index 000000000..63a9fbdf1 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/utm-parameters.md @@ -0,0 +1,96 @@ +--- +sidebarPos: 21 +sidebarTitle: UtmParameters +--- + +# UtmParameters + +**File**: `src/Services/UtmParameters.php` +**Facade**: `Unusualify\Modularity\Facades\Utm` + +Captures and persists the five standard UTM marketing tracking parameters from incoming HTTP requests to the PHP session. `UtmMiddleware` triggers capture automatically on every request. + +## Tracked Parameters + +| Parameter | Description | +|-----------|-------------| +| `utm_source` | Traffic source (e.g. `google`, `newsletter`) | +| `utm_medium` | Marketing medium (e.g. `cpc`, `email`) | +| `utm_campaign` | Campaign name | +| `utm_term` | Paid keyword | +| `utm_content` | Differentiates ads/links in the same campaign | + +## Environment Variables + +| Variable | Default | Effect | +|----------|---------|--------| +| `MODULARITY_UTM_DISABLED` | `false` | Disable UTM capture entirely — all methods become no-ops | +| `MODULARITY_UTM_TEMPORARY` | `false` | Do not persist parameters between requests — session is reset on every boot | +| `MODULARITY_UTM_HANDLE_REQUEST` | `false` | Parse UTM params from the current HTTP request automatically on service construction | + +## Key Methods + +### Capture + +| Method | Description | +|--------|-------------| +| `handleRequest()` | Parse UTM params from the current request and store them in the session. If `isPersisted()` is true, merges with existing values; otherwise overwrites. | + +### Read + +| Method | Description | +|--------|-------------| +| `getParameters()` | Return all five parameters as an associative array | +| `$utm->utm_source` | Direct property access for any individual parameter | +| `$utm->getUtmSourceParameter()` | Magic getter — `get{StudlyParam}Parameter()` works for any of the five params | + +### Write + +| Method | Description | +|--------|-------------| +| `setParameters(array $data)` | Overwrite all stored UTM parameters | +| `mergeParameters(array $data)` | Update only the provided parameters, leaving others intact | +| `resetParameters()` | Clear all UTM parameters from the session | + +## Middleware Integration + +`UtmMiddleware` is registered automatically by Modularous and calls `handleRequest()` on every request when `MODULARITY_UTM_HANDLE_REQUEST=true`. + +To enable automatic capture, add to your `.env`: + +```dotenv +MODULARITY_UTM_HANDLE_REQUEST=true +``` + +## Usage Example + +```php +use Unusualify\Modularity\Facades\Utm; + +// Read all parameters +$params = Utm::getParameters(); +// ['utm_source' => 'google', 'utm_medium' => 'cpc', ...] + +// Access individual parameter +$source = Utm::getUtmSourceParameter(); + +// Attach UTM data to a model on checkout +$order->utm_source = Utm::utm_source; +$order->utm_campaign = Utm::utm_campaign; +$order->save(); + +// Clear after use +Utm::resetParameters(); +``` + +## Session Storage + +Parameters are stored in the session under `utm_parameters.*`: + +``` +utm_parameters.utm_source = 'google' +utm_parameters.utm_medium = 'cpc' +utm_parameters.utm_campaign = 'summer-sale' +utm_parameters.utm_term = null +utm_parameters.utm_content = null +``` diff --git a/docs/src/pages/system-reference/backend/services/view/modularity-navigation.md b/docs/src/pages/system-reference/backend/services/view/modularity-navigation.md new file mode 100644 index 000000000..cbd5d368f --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/view/modularity-navigation.md @@ -0,0 +1,87 @@ +--- +sidebarPos: 4 +sidebarTitle: ModularityNavigation +--- + +# ModularityNavigation + +**File**: `src/Services/View/ModularityNavigation.php` + +`ModularityNavigation` builds the sidebar navigation array that is passed to the frontend. It resolves route names, applies permission checks, resolves badge values, and marks the active item based on the current request URL. + +## Navigation Types + +Each sidebar config can contain entries keyed by user type: + +| Type | Audience | +|------|----------| +| `default` | Authenticated users | +| `superadmin` | Super-admin users only | +| `client` | Client-role users | +| `guest` | Unauthenticated visitors | + +## Key Methods + +| Method | Description | +|--------|-------------| +| `systemMenu()` | Returns the navigation array built from Modularity's system modules | +| `modulesMenu()` | Returns the navigation array built from application modules | +| `sidebarMenuItem($array)` | Processes a single nav item: checks `can`/`allowedRoles`, resolves route, evaluates badge, filters invisible items | +| `formatSidebarMenus(&$array)` | Iterates all user types and calls `formatSidebarMenu` on each | +| `formatSidebarMenu($array)` | Recursively processes each item; removes items that fail permission checks | +| `setActiveSidebarItems(&$items)` | Traverses the tree and sets `is_active = 1` on items whose route matches the current URL | +| `unsetMenuKeys(&$array)` | Re-indexes arrays to remove string keys (ensures JSON encodes as array, not object) | +| `sidebarMenuFromModules($modules)` | Builds the full navigation tree from a collection of module instances | + +## sidebarMenuItem Processing + +For each nav item in the config array: + +1. **Permission check** — if `can` is set, evaluates `$user->can($array['can'])`; if `allowedRoles` is set, calls `isAllowedItem()`; returns `false` (item removed) on failure. +2. **Nested items** — recursively processes `items` / `menuItems` arrays. +3. **Route resolution** — converts `route_name` to a full route URL via `Route::hasAdmin()`; returns `false` if the route does not exist. +4. **Badge resolution** — if `connector` is set, runs it and assigns the result; if `badge` is a `callable`, invokes it; badges `< 1` are removed to avoid showing zeros. +5. **Active detection** — sets `is_active = 1` if the item's route equals the current request URL. + +## Badge Active Styling + +When a nav item is both active and has a numeric badge, `ModularityNavigation` applies contrasting badge styling: + +```php +$item['badgeProps'] = ['color' => 'white', 'class' => 'primary']; +$item['iconProps'] = []; +unset($item['class']); +``` + +## Module-Driven Navigation + +`sidebarMenuFromModules()` discovers route names and headlines from module metadata, automatically building the nav tree from the modules that are enabled in the application. Sub-modules appear as nested items under their parent module. + +## Example Config Shape + +```php +// In a module's nav config +return [ + 'default' => [ + [ + 'name' => 'Orders', + 'icon' => 'mdi-cart', + 'route_name' => 'orders.index', + 'badge' => 'Orders|index^Order->pendingCount', // connector + 'can' => 'view-orders', + ], + ], +]; +``` + +After processing, the frontend receives: + +```json +{ + "name": "Orders", + "icon": "mdi-cart", + "route": "https://app.example.com/admin/orders", + "badge": 14, + "is_active": 0 +} +``` diff --git a/docs/src/pages/system-reference/backend/services/view/overview.md b/docs/src/pages/system-reference/backend/services/view/overview.md new file mode 100644 index 000000000..94753c3f5 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/view/overview.md @@ -0,0 +1,35 @@ +--- +sidebarPos: 7 +sidebarTitle: Overview +--- + +# View Services + +**Directory**: `src/Services/View/` + +The View namespace provides PHP-side builders for constructing Vue component schemas and navigation structures that are passed to the frontend via Inertia props. + +## Classes + +| Class | Description | Page | +|-------|-------------|------| +| [UComponent](/system-reference/backend/services/view/u-component) | Fluent builder for a single Vue component schema array | [→](/system-reference/backend/services/view/u-component) | +| [UWidget](/system-reference/backend/services/view/u-widget) | Extends `UComponent`; wires Connector data into dashboard widget schemas | [→](/system-reference/backend/services/view/u-widget) | +| [UWrapper](/system-reference/backend/services/view/u-wrapper) | Static factory for grid-based layout wrappers (`v-row` / `v-col`) | [→](/system-reference/backend/services/view/u-wrapper) | +| [ModularityNavigation](/system-reference/backend/services/view/modularity-navigation) | Builds sidebar navigation arrays with permissions, badges, and active states | [→](/system-reference/backend/services/view/modularity-navigation) | + +## Rendering Model + +`UComponent::render()` returns a plain PHP array: + +```php +[ + 'tag' => 'ue-form', + 'attributes' => [...], + 'slots' => [...], + 'directives' => [...], + 'elements' => [...], // only present when children exist +] +``` + +This array is serialized to JSON by Inertia and consumed by the corresponding Vue component on the frontend. The `tag` key maps directly to a registered Vue component name. diff --git a/docs/src/pages/system-reference/backend/services/view/u-component.md b/docs/src/pages/system-reference/backend/services/view/u-component.md new file mode 100644 index 000000000..d27239d85 --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/view/u-component.md @@ -0,0 +1,82 @@ +--- +sidebarPos: 1 +sidebarTitle: UComponent +--- + +# UComponent + +**File**: `src/Services/View/UComponent.php` +**Extends**: `Illuminate\View\Component` + +`UComponent` is a fluent builder that constructs a PHP array schema representing a single Vue component. The schema is serialized to JSON by Inertia and rendered by the matching Vue component on the frontend. + +## Properties + +| Property | Type | Description | +|----------|------|-------------| +| `$tag` | `string` | Vue component tag name (e.g. `ue-form`, `v-row`) | +| `$attributes` | `array` | Component props/attributes | +| `$slots` | `array` | Named slot contents | +| `$directives` | `array` | Vue directives | +| `$elements` | `array\|null` | Child component schemas | + +## Methods + +| Method | Description | +|--------|-------------| +| `make()` | Static factory — returns a new `UComponent` instance | +| `makeComponent($tag, $attributes, $elements, $slots, $directives)` | Configure all properties in one call; returns `$this` | +| `setTag($tag)` | Set the component tag; returns `$this` | +| `setAttributes($attributes)` | Set props array (passes through `hydrateAttributes`); returns `$this` | +| `setSlots($slots)` | Set named slots; returns `$this` | +| `setDirectives($directives)` | Set Vue directives; returns `$this` | +| `setElements($elements)` | Set child elements (ignored when `''`); returns `$this` | +| `addChildren($element)` | Append a child (string, array, or `UComponent`); returns `$this` | +| `addSlot($slotName, $slotContent)` | Add a single named slot; returns `$this` | +| `render()` | Return the complete schema array | + +## Magic Methods + +`UComponent` intercepts both instance and static calls matching these patterns: + +| Pattern | Example | Behaviour | +|---------|---------|-----------| +| `make{Tag}(...)` | `UComponent::makeUeForm($attrs)` | Calls `makeComponent('ue-form', ...)` | +| `make{Tag}(...)` | `UComponent::makeVRow()` | Calls `makeComponent('v-row', ...)` | +| `addChildren{Tag}(...)` | `->addChildrenUeInput(...)` | Calls `addChildren('ue-input', ...)` | + +Tag names in method names are converted from StudlyCase to kebab-case automatically (`UeForm` → `ue-form`, `VRow` → `v-row`). + +## render() Output + +```php +[ + 'tag' => 'ue-form', + 'attributes' => ['model' => 'user', 'action' => '/users'], + 'slots' => [], + 'directives' => [], + 'elements' => [ + ['tag' => 'ue-input', 'attributes' => ['name' => 'email'], ...], + ], +] +``` + +The `elements` key is only present when child components have been added. + +## Example + +```php +use Unusualify\Modularity\Services\View\UComponent; + +$form = UComponent::makeUeForm(['model' => 'user']) + ->addChildren( + UComponent::makeUeInput(['name' => 'email', 'type' => 'email']) + ) + ->addChildren( + UComponent::makeUeInput(['name' => 'password', 'type' => 'password']) + ); + +return Inertia::render('UserCreate', [ + 'form' => $form->render(), +]); +``` diff --git a/docs/src/pages/system-reference/backend/services/view/u-widget.md b/docs/src/pages/system-reference/backend/services/view/u-widget.md new file mode 100644 index 000000000..82867c7fd --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/view/u-widget.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 2 +sidebarTitle: UWidget +--- + +# UWidget + +**File**: `src/Services/View/UWidget.php` +**Extends**: `UComponent` + +`UWidget` extends `UComponent` with **Connector-aware attribute hydration** for dashboard widgets. When an attributes array contains a `connector` key, `UWidget` calls `init_connector()` to fetch live data and merges it into the component schema automatically. + +## Difference from UComponent + +`UWidget::setAttributes()` is overridden to: + +1. Extract grid column attributes (`col` key → `$this->attributes`). +2. Dispatch to a component-specific method based on the `component` key (e.g. `component: 'ue-table'` → `setTableAttributes()`). +3. Fall back to the generic `setComponentAttributes()` when no specific handler exists. + +## Built-in Attribute Handlers + +| Method | Triggered when `component` is | Behaviour | +|--------|-------------------------------|-----------| +| `setTableAttributes($attrs)` | `ue-table` | Runs the connector, converts items to array, merges `items`, `route`, `repository`, `module` into the table's attributes | +| `setComponentAttributes($attrs)` | Any other component | Same as above — generic connector + merge | +| `setBoardInformationPlusAttributes($attrs)` | `ue-board-information-plus` | Iterates over `cards`, runs a connector per card, attaches `data` to each card | + +## Connector Integration + +When an attributes array contains `'connector' => 'Module|route^Type->method'`, the widget resolves it at render time: + +```php +$data = init_connector($attributes['connector']); +// $data = ['items' => Collection, 'route' => '...', 'repository' => '...', 'module' => '...'] +``` + +The resolved data is merged into the component's attribute array so the frontend receives pre-fetched records. + +## Example + +```php +use Unusualify\Modularity\Services\View\UWidget; + +$widget = UWidget::makeVCol([ + 'component' => 'ue-table', + 'connector' => 'Orders|index^Order->getLatest', + 'attributes' => ['headers' => ['id', 'total', 'status']], +]); + +return Inertia::render('Dashboard', [ + 'widget' => $widget->render(), +]); +``` + +The frontend receives a fully populated `items` array inside the `ue-table` attributes without any additional controller code. diff --git a/docs/src/pages/system-reference/backend/services/view/u-wrapper.md b/docs/src/pages/system-reference/backend/services/view/u-wrapper.md new file mode 100644 index 000000000..704ff4b7f --- /dev/null +++ b/docs/src/pages/system-reference/backend/services/view/u-wrapper.md @@ -0,0 +1,70 @@ +--- +sidebarPos: 3 +sidebarTitle: UWrapper +--- + +# UWrapper + +**File**: `src/Services/View/UWrapper.php` + +`UWrapper` is a **static factory** for building grid-layout wrapper schemas. It composes `UComponent` instances into `v-row` / `v-col` structures without requiring instantiation. + +## Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `make()` | `static make(): self` | Returns a new instance (rarely needed) | +| `makeGridSection` | `static makeGridSection($elements, $rowAttributes, $colAttributes): array` | Wraps an array of components in a `v-row` with one `v-col` per element | +| `makeFormWrapper` | `static makeFormWrapper($forms): array` | Converts an array of form attribute arrays into a grid of `ue-form` components | +| `makeProfileWrapper` | `static makeProfileWrapper($elements, $attributes)` | Stub — not yet implemented | + +## makeGridSection + +Produces a `v-row` → `v-col[]` schema. Each element in `$elements` becomes one column. + +```php +$grid = UWrapper::makeGridSection( + elements: [$componentA, $componentB], + rowAttributes: ['class' => 'my-4'], + colAttributes: ['cols' => 12, 'lg' => 6], +); +``` + +**Element types accepted:** + +| Type | Behaviour | +|------|-----------| +| `UComponent` instance | Appended directly as a child of the column | +| Associative array with `content` key | Contents split across the column; `parent_attributes` merged into col attributes | +| Plain array with multiple items | Each item appended as a child of the same column | +| Plain array with one item | That item appended as the column's child | + +Default column attributes: `['class' => '', 'cols' => 12, 'lg' => 6]` — merged with `$colAttributes`. + +## makeFormWrapper + +Shorthand for rendering a list of form schemas as a responsive grid: + +```php +$layout = UWrapper::makeFormWrapper([ + ['model' => 'profile', 'action' => '/profile'], + ['model' => 'password', 'action' => '/password'], +]); +``` + +Internally calls `makeGridSection()` after wrapping each element in `UComponent::makeUeForm()`. + +## Example + +```php +use Unusualify\Modularity\Services\View\UComponent; +use Unusualify\Modularity\Services\View\UWrapper; + +$grid = UWrapper::makeGridSection([ + UComponent::makeUeCard(['title' => 'Revenue']), + UComponent::makeUeCard(['title' => 'Users']), + UComponent::makeUeCard(['title' => 'Orders']), +], [], ['cols' => 12, 'lg' => 4]); + +return Inertia::render('Dashboard', ['grid' => $grid]); +``` diff --git a/docs/src/pages/system-reference/backend/support/command-discovery.md b/docs/src/pages/system-reference/backend/support/command-discovery.md new file mode 100644 index 000000000..d483d2375 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/command-discovery.md @@ -0,0 +1,56 @@ +--- +sidebarPos: 2 +sidebarTitle: CommandDiscovery +--- + +# CommandDiscovery + +`Unusualify\Modularity\Support\CommandDiscovery` + +Scans one or more glob paths and returns an array of fully qualified class names (FQCNs) for concrete, instantiable `Illuminate\Console\Command` subclasses. Used by the Modularous service provider to register all artisan commands without a hand-maintained list. + +## Static API + +```php +CommandDiscovery::discover(array $paths, array $exclude = []): array<string> +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$paths` | `string[]` | Glob patterns — e.g. `__DIR__ . '/../Console/**/*.php'` | +| `$exclude` | `string[]` | Short class names to skip (for legacy compatibility) | + +Returns an array of unique FQCNs that pass all filters. + +## Filtering Rules + +A file is silently skipped if it: + +- Declares an `abstract class`, `interface`, `enum`, or `trait` +- Cannot be found in the class map (`class_exists` returns false) +- Is not instantiable (abstract after reflection) +- Does not extend `Illuminate\Console\Command` + +Comments and string literals are stripped before checking declarations to avoid false positives in docblocks. + +## Example + +```php +use Unusualify\Modularity\Support\CommandDiscovery; + +$commands = CommandDiscovery::discover([ + __DIR__ . '/Console/Cache/*.php', + __DIR__ . '/Console/Coverage/*.php', +]); + +// Register with Artisan +$this->commands($commands); +``` + +## How It Works + +1. For each glob path, `glob()` expands the pattern to a list of files. +2. The file content is read and stripped of comments/strings. +3. Regex checks exclude non-class declarations. +4. The namespace is extracted from the file and the FQCN is assembled. +5. `ReflectionClass` confirms the class is instantiable and extends `Command`. diff --git a/docs/src/pages/system-reference/backend/support/coverage-analyzer.md b/docs/src/pages/system-reference/backend/support/coverage-analyzer.md new file mode 100644 index 000000000..3c198ecca --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/coverage-analyzer.md @@ -0,0 +1,109 @@ +--- +sidebarPos: 3 +sidebarTitle: CoverageAnalyzer +--- + +# CoverageAnalyzer + +`Unusualify\Modularity\Support\CoverageAnalyzer` + +Parses a PHPUnit Clover XML report and identifies methods with low or zero coverage. This is the core engine behind all five [Coverage commands](/guide/console/coverage/overview). + +## Constructor + +```php +new CoverageAnalyzer(string $cloverDir, string $cloverName) +``` + +Throws `InvalidArgumentException` if the file does not exist or is not readable. + +## Fluent Configuration + +| Method | Default | Description | +|--------|---------|-------------| +| `filterByFiles(array $files)` | _(all)_ | Restrict analysis to specific file paths | +| `setCoverageThreshold(float $threshold)` | `0.0` | Only return methods below this coverage % | +| `skipMagicMethods(bool $skip = true)` | `true` | Exclude `__construct`, `__toString`, etc. | +| `skipPrivateMethods(bool $skip = true)` | `false` | Exclude private methods | +| `skipProtectedMethods(bool $skip = true)` | `false` | Exclude protected methods | + +All configuration methods return `$this` for chaining. + +## Methods + +### `analyze(): array` + +Analyze all files (or the filtered set) and return an array of method records for every method below the threshold. + +Each record contains: + +```php +[ + 'class' => 'App\\Models\\User', + 'method' => 'getFullName', + 'file' => '/var/www/app/Models/User.php', + 'line' => 42, + 'visibility' => 'public', + 'complexity' => 3, + 'crap' => 4.5, + 'coverage' => 0.0, // percentage + 'execution_count' => 0, // number of times method was called in tests + 'lines' => [ + 'total' => 5, + 'covered' => 0, + 'uncovered'=> 5, + 'details' => [/* per-statement line records */], + ], +] +``` + +### `analyzeFile(string $filePath): array` + +Analyze a single file path directly. + +### `getMethodCoverage(string $filePath, string $methodName): ?array` + +Return coverage details for one specific method, or `null` if not found. + +### `getOverallStatistics(): array` + +Return project-level aggregates from the Clover `<metrics>` element: + +```php +[ + 'files' => 42, + 'methods' => 380, + 'covered_methods' => 290, + 'method_coverage_percent' => 76.32, + 'statement_coverage_percent' => 81.5, + // ... +] +``` + +### `getErrors(): array` / `hasErrors(): bool` + +Retrieve any non-fatal parsing warnings. + +## Example + +```php +use Unusualify\Modularity\Support\CoverageAnalyzer; + +$analyzer = (new CoverageAnalyzer('storage/app', 'clover.xml')) + ->setCoverageThreshold(80.0) + ->skipMagicMethods(); + +$uncovered = $analyzer->analyze(); + +foreach ($uncovered as $method) { + echo "{$method['class']}::{$method['method']} — {$method['coverage']}%\n"; +} + +$stats = $analyzer->getOverallStatistics(); +echo "Overall method coverage: {$stats['method_coverage_percent']}%\n"; +``` + +## Related + +- [coverage:analyze](/guide/console/coverage/coverage-analyze) — CLI wrapper +- [coverage:generate-tests](/guide/console/coverage/coverage-generate-tests) — uses `CoverageAnalyzer` to find candidates for test generation diff --git a/docs/src/pages/system-reference/backend/support/decomposers.md b/docs/src/pages/system-reference/backend/support/decomposers.md new file mode 100644 index 000000000..cded5e121 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/decomposers.md @@ -0,0 +1,114 @@ +--- +sidebarPos: 4 +sidebarTitle: Decomposers +--- + +# Decomposers + +The `Support\Decomposers` sub-namespace contains three parser classes used by the code generators to convert compact CLI-style definition strings into structured PHP arrays. + +--- + +## ModelRelationParser + +`Unusualify\Modularity\Support\Decomposers\ModelRelationParser` + +Parses a relation definition string (as entered in `make:model --relations`) into an array of Eloquent relationship descriptors, and can render the resulting `public function …()` method stubs. + +### Supported Relation Types + +`belongsTo`, `hasOne`, `hasMany`, `hasOneThrough`, `hasManyThrough`, `belongsToMany`, `morphTo`, `morphOne`, `morphToMany` + +### Input Format + +Each relation is expressed as a colon-separated string: + +``` +company:belongsTo +tags:belongsToMany:tag +commentable:morphTo +``` + +### Key Methods + +| Method | Description | +|--------|-------------| +| `parse(string $relations): array` | Parse the string and return an array of relation descriptors | +| `toArray(): array` | Call `parse()` on the stored relations | +| `render(): string` | Render all relations as PHP method stubs | + +### Example + +```php +use Unusualify\Modularity\Support\Decomposers\ModelRelationParser; + +$parser = new ModelRelationParser('company:belongsTo,tags:belongsToMany:tag'); +$rendered = $parser->render(); +// outputs public function company() { return $this->belongsTo(Company::class); } ... +``` + +--- + +## SchemaParser (Decomposers) + +`Unusualify\Modularity\Support\Decomposers\SchemaParser` + +Parses a field-definition string (as entered in `make:model --fields`) into an array of input schema descriptors consumed by hydrate and Vue input generators. + +### Input Format + +Fields are comma-separated, each with `name:type[:modifier…]` syntax: + +``` +title:string,body:textarea,published_at:datetime:nullable,status:select +``` + +### Key Methods + +| Method | Description | +|--------|-------------| +| `parse(string $schema): array` | Parse the string and return field descriptors | +| `toArray(): array` | Call `parse()` on the stored schema | +| `render(): string` | Render input schema stubs for use in Hydrate files | + +--- + +## ValidatorParser + +`Unusualify\Modularity\Support\Decomposers\ValidatorParser` + +Parses a validation-rules string (as entered in `make:model --rules`) into a `['field' => 'rules']` array suitable for pasting into a `StoreRequest` / `UpdateRequest`. + +### Input Format + +Rules are `&`-separated, each field expressed as `field=rule1|rule2`: + +``` +title=required|string|max:255&body=nullable|string&published_at=nullable|date +``` + +### Key Methods + +| Method | Description | +|--------|-------------| +| `parse(mixed $rules): array` | Parse and return `['field' => 'rules_string']` | +| `toArray(): array` | Call `parse()` on the stored rules | +| `toReplacement(): string` | Render the array as a PHP `array_export` string with correct indentation for insertion into a stub | +| `getFields(): array` | Return the raw `&`-split field tokens | + +### Example + +```php +use Unusualify\Modularity\Support\Decomposers\ValidatorParser; + +$parser = new ValidatorParser('title=required|string|max:255&body=nullable|string'); +$rules = $parser->toArray(); +// ['title' => 'required|string|max:255', 'body' => 'nullable|string'] +``` + +--- + +## Related + +- [Migrations\SchemaParser](./migrations-schema-parser) — renders migration `$table->…` code from the same schema string format +- [Generators](/system-reference/backend/generators/overview) — consume all three parsers during scaffolding diff --git a/docs/src/pages/system-reference/backend/support/file-loader.md b/docs/src/pages/system-reference/backend/support/file-loader.md new file mode 100644 index 000000000..0a3ec1a31 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/file-loader.md @@ -0,0 +1,46 @@ +--- +sidebarPos: 5 +sidebarTitle: FileLoader +--- + +# FileLoader + +`Unusualify\Modularity\Support\FileLoader` + +Extends Laravel's `Illuminate\Translation\FileLoader` to support multiple translation search paths and expose runtime path/group introspection. It is bound in the service container and powers Modularous multi-module translation resolution. + +## Extended API + +| Method | Return | Description | +|--------|--------|-------------| +| `getPaths(): array` | `string[]` | Return all registered translation root directories | +| `getGroups(): array` | `string[]` | Scan all paths recursively and return unique translation group names (PHP file basenames) | +| `addPath(array\|string $path)` | `void` | Append one or more additional search paths at runtime | + +The constructor signature mirrors Laravel's own loader: + +```php +new FileLoader(Filesystem $files, array|string $path) +``` + +## How It Works + +`getGroups()` performs a recursive filesystem scan — for every `.php` file found under any registered path, it strips the `.php` extension and collects unique group names. This lets Modularous enumerate all available translation keys without hard-coding file names. + +## Example + +```php +/** @var \Unusualify\Modularity\Support\FileLoader $loader */ +$loader = app('translation.loader'); + +// Add a module's translation path at runtime +$loader->addPath(module_path('Blog', 'Resources/lang')); + +// List all registered translation groups +$groups = $loader->getGroups(); +// ['validation', 'auth', 'pagination', 'blog', ...] +``` + +## Related + +- [Translation service](/system-reference/backend/services/translation) — uses `FileLoader` to sync translation files diff --git a/docs/src/pages/system-reference/backend/support/finder.md b/docs/src/pages/system-reference/backend/support/finder.md new file mode 100644 index 000000000..117d9a6b2 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/finder.md @@ -0,0 +1,78 @@ +--- +sidebarPos: 6 +sidebarTitle: Finder +--- + +# Finder + +`Unusualify\Modularity\Support\Finder` + +Resolves Eloquent model and repository FQCN by table name, route name, or trait. Searches all enabled modules as well as the host application's `app/Models` and `app/Repositories` directories. Also accessible via the `UFinder` facade. + +## Methods + +### `getModel(string $table): string|false` + +Find the first model class whose `getTable()` returns `$table`. Searches module `Entities/` directories first, then `app/Models/`. + +```php +$class = UFinder::getModel('blog_posts'); +// => 'Modules\Blog\Entities\Post' +``` + +### `getRouteModel(string $routeName, bool $asClass = false): string|object|false` + +Resolve the model whose short class name matches the StudlyCase of `$routeName`. + +```php +$class = UFinder::getRouteModel('blog-posts'); +// => 'Modules\Blog\Entities\Post' + +$instance = UFinder::getRouteModel('blog-posts', asClass: true); +// => Modules\Blog\Entities\Post instance +``` + +### `getRepository(string $table): string|false` + +Find the repository class whose model resolves to `$table`. + +```php +$repoClass = UFinder::getRepository('blog_posts'); +// => 'Modules\Blog\Repositories\PostRepository' +``` + +### `getRouteRepository(string $routeName, bool $asClass = false): string|object|false` + +Resolve a repository by the StudlyCase of the route name (expects `{Name}Repository` naming convention). + +### `getPossibleModels(string $routeName): array` + +Return all model FQCNs whose short name matches `$routeName` — useful when multiple modules define a model with the same name. + +### `getModelsWithTrait(string $trait): array` + +Return all Eloquent model FQCNs in the project that use the given trait. + +```php +$models = UFinder::getModelsWithTrait(\Unusualify\Modularity\Traits\HasPayment::class); +``` + +### `getAllModels(): Collection` + +Scan the composer classmap and return all user-defined, concrete, non-abstract Eloquent models as a Collection of FQCNs. + +### `getClasses(string $path): array` + +Use `composer/class-map-generator` to list all classes defined under the given directory. + +## Facade + +```php +use Unusualify\Modularity\Facades\UFinder; + +$model = UFinder::getRouteModel('products'); +``` + +## Related + +- [Generators](/system-reference/backend/generators/overview) — use `Finder` internally to resolve models during scaffolding diff --git a/docs/src/pages/system-reference/backend/support/host-routing.md b/docs/src/pages/system-reference/backend/support/host-routing.md new file mode 100644 index 000000000..049cb80bf --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/host-routing.md @@ -0,0 +1,96 @@ +--- +sidebarPos: 7 +sidebarTitle: HostRouting +--- + +# HostRouting & HostRouteRegistrar + +These two classes implement Modularous **multi-tenant host-based routing** — the ability to scope route groups by the incoming request's `Host` header, automatically resolving the active tenant model. + +Both classes live in `Unusualify\Modularity\Support` and are constructed with the application instance and a base host name. + +--- + +## HostRouting + +`Unusualify\Modularity\Support\HostRouting` + +The primary class. It holds the active tenant model, builds the `Route::group()` options (domain, prefix, middleware), and exposes methods for registering host-aware route groups. + +### Constructor + +```php +new HostRouting( + Application $app, + string $baseHostName, + array $hostableClasses = [], + array $options = [], +) +``` + +### Key Methods + +| Method | Description | +|--------|-------------| +| `group(callable $callback)` | Register a `Route::group` with the resolved domain/prefix/middleware options | +| `setModel(array\|string $model)` | Set the hostable model class(es) and re-resolve the active tenant | +| `setOptions(array $options)` | Merge domain/prefix/middleware options; accepts `model` and `middleware` keys | +| `getHostModel()` | Return the resolved tenant model instance (or `null` if no match) | +| `getBaseHostName(): string` | Return the fallback host name | +| `getRouteArguments(): array` | Merge tenant `hostableRouteArguments()` with current route parameters | +| `getRouteParameters(): array` | Return the raw route parameters for the current request | +| `combineHostModels(): Collection` | Fetch all records from the hostable model classes | +| `classesIsHostable(): bool` | Return `true` if all hostable model tables exist in the database | + +### How the Tenant Is Resolved + +On construction (and on `setModel()`), `setHostModel()` calls `combineHostModels()` to load all records from the given model class(es) and picks the one whose `url` attribute matches `$request->getHost()`. If no match is found, `getHostModel()` returns `null` and the base host name is used as the domain. + +### Usage + +```php +// In a route file: +$hostRouting = app(HostRouting::class, [ + 'baseHostName' => config('app.url'), + 'hostableClasses' => [Tenant::class], +]); + +$hostRouting->setModel(Tenant::class)->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index']); +}); +``` + +--- + +## HostRouteRegistrar + +`Unusualify\Modularity\Support\HostRouteRegistrar` + +An older, more opaque variant used internally by Modularous own route files. It exposes only two fluent callables (`host()` and `group()`) and a set of `allowedAttributes` (`middleware`, `name`). Prefer `HostRouting` for new code. + +### Key Difference + +`HostRouteRegistrar` builds group options lazily via a `__call` proxy. `group()` must always be called last after chaining attribute setters. + +```php +$registrar->host([Tenant::class])->group(function () { + // routes +}); +``` + +--- + +## Route Group Options Produced + +Regardless of which class is used, the resolved options follow this shape: + +| Key | Tenant found | No tenant | +|-----|-------------|-----------| +| `domain` | `$tenant->url` | `$baseHostName` | +| `prefix` | Tenant's `hostableChildRouteParameters()` joined with `/` | Model's `hostableRouteBindingParameter()` per class | +| `middleware` | `['hostable']` (+ any extras) | same | + +## Related + +- [ModularityRoutes](./modularity-routes) — registers the `hostable` middleware alias +- [`HasHostable` trait](/system-reference/backend/entity-traits/overview) — models must implement `hostables()`, `hostableRouteArguments()`, etc. diff --git a/docs/src/pages/system-reference/backend/support/migrations-schema-parser.md b/docs/src/pages/system-reference/backend/support/migrations-schema-parser.md new file mode 100644 index 000000000..13a7b23f8 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/migrations-schema-parser.md @@ -0,0 +1,96 @@ +--- +sidebarPos: 8 +sidebarTitle: Migrations SchemaParser +--- + +# Migrations\SchemaParser + +`Nwidart\Modules\Support\Migrations\SchemaParser` + +Converts a compact field-definition string into the `$table->…` PHP snippets written inside migration `up()` / `down()` methods. Used exclusively by Modularous migration generator stubs. + +::: info Namespace note +This class lives under the `Nwidart\Modules` namespace because Modularous extends nwidart/laravel-modules. The file is at `src/Support/Migrations/SchemaParser.php`. +::: + +## Input Format + +A schema string is a comma-separated list of `column:type[:modifier…]` tokens: + +``` +name:string,description:text,published_at:timestamp:nullable,company:belongsTo,settings:json +``` + +Special column shorthand: + +| Token | Expands to | +|-------|-----------| +| `remember_token` | `->rememberToken()` | +| `soft_delete` | `->softDeletes()` | +| `column:morphTo` | Two columns: `{column}_type:string:nullable` + `{column}_id:unsignedBigInteger:nullable` | +| `column:belongsToMany` | Pivot table — no column emitted in this migration | +| `column:hasOne` | No column emitted in this migration | + +## API + +### `parse(string $schema): array` + +Parse the schema string and return `['column' => ['type', 'modifier1', …]]`. + +```php +$parser = new SchemaParser('title:string,body:text:nullable'); +$parsed = $parser->parse('title:string,body:text:nullable'); +// ['title' => ['string'], 'body' => ['text', 'nullable']] +``` + +### `toArray(): array` + +Equivalent to `parse($this->schema)`. + +### `render() / up(): string` + +Render the `$table->…;` PHP lines for the `up()` migration method. + +```php +$parser = new SchemaParser('title:string,company:belongsTo,published_at:timestamp:nullable'); +echo $parser->render(); +``` + +Output: + +```php + $table->string('title'); + $table->foreignId('company_id')->constrained()->onUpdate('cascade')->onDelete('cascade'); + $table->timestamp('published_at')->nullable(); +``` + +### `down(): string` + +Render the `$table->dropColumn(…);` lines for the `down()` method. + +### `createField(string $column, array $attributes, string $type = 'add'): string` + +Build a single field line. `$type` can be `'add'` or `'remove'`. + +## Custom Attributes + +Two hardcoded shorthand tokens: + +| Column name | Expands to | +|-------------|-----------| +| `remember_token` | `->rememberToken()` | +| `soft_delete` | `->softDeletes()` | + +## Relation Handling + +| Relation type | `up()` behaviour | `down()` behaviour | +|---------------|------------------|--------------------| +| `belongsTo` | `foreignId('{col}_id')->constrained()->…` | `dropColumn('{col}_id')` | +| `morphTo` | Two columns: `_type` (string) + `_id` (unsignedBigInteger) | Drop both | +| `belongsToMany` | Skipped (pivot handled separately) | Skipped | +| `hasOne` | Skipped (FK on the related table) | Skipped | + +## Related + +- [Decomposers\SchemaParser](./decomposers) — parses the same string format for Hydrate/Vue input generation +- [Generators](/system-reference/backend/generators/overview) — passes the schema string to both parsers during `make:model` diff --git a/docs/src/pages/system-reference/backend/support/modularity-routes.md b/docs/src/pages/system-reference/backend/support/modularity-routes.md new file mode 100644 index 000000000..acc6defcc --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/modularity-routes.md @@ -0,0 +1,78 @@ +--- +sidebarPos: 9 +sidebarTitle: ModularityRoutes +--- + +# ModularityRoutes + +`Unusualify\Modularity\Support\ModularityRoutes` + +Centralises all Modularous route configuration: the admin group options (name prefix, domain/prefix), middleware alias registration, and middleware group definitions. Resolved from the service container by the route service provider. + +## Methods + +### `configureRoutePatterns(): void` + +Reads `modularity.route_patterns` from config and calls `Route::pattern()` for each entry. Run once at boot to constrain route parameter formats (e.g. `{id}` to digits only). + +### `groupOptions(): array` + +Returns the options array passed to the top-level admin `Route::group()`: + +```php +[ + 'as' => 'admin.', // route name prefix + 'domain' => 'admin.app.test', // when admin URL is separate + // OR + 'prefix' => 'admin', // when admin is a path prefix + 'domain' => 'app.test', +] +``` + +### Middleware Stack Methods + +| Method | Middlewares Included | +|--------|---------------------| +| `webMiddlewares()` | `web`, `modularity.log`, `modularity.core` | +| `webPanelMiddlewares()` | `web.auth`, `modularity.log`, `modularity.core`, `modularity.panel` | +| `apiMiddlewares()` | `api`, `modularity.log`, `modularity.core` | +| `apiPanelMiddlewares()` | `api.auth`, `modularity.log`, `modularity.core`, `modularity.panel` | +| `defaultMiddlewares()` | `modularity.log`, `modularity.core` | +| `defaultPanelMiddlewares()` | `modularity.panel` | + +### `generateRouteMiddlewares(): void` + +Registers all Modularous middleware aliases and groups with the Laravel router: + +**Aliases registered:** + +| Alias | Middleware Class | +|-------|-----------------| +| `modularity.auth` | `AuthenticateMiddleware` | +| `modularity.guest` | `RedirectIfAuthenticatedMiddleware` | +| `modularity.utm` | `UtmMiddleware` | +| `modularity.log` | `LogMiddleware` | +| `modularity.language` | `LanguageMiddleware` | +| `modularity.impersonate` | `ImpersonateMiddleware` | +| `modularity.loadLocalizedConfig` | `LoadLocalizedConfig` | +| `modularity.navigation` | `NavigationMiddleware` | +| `authorization` | `AuthorizationMiddleware` | +| `modularity.company.registration` | `CompanyRegistrationMiddleware` | +| `modularity.redirector` | `RedirectorMiddleware` | +| `hostable` | `HostableMiddleware` | + +**Groups registered:** + +| Group | Members | +|-------|---------| +| `web.auth` | `web`, `modularity.auth:{guard}` | +| `api.auth` | `api`, `throttle:api`, `modularity.auth:{guard}` | +| `modularity.core` | `modularity.utm`, `modularity.impersonate`, `modularity.language`, `modularity.loadLocalizedConfig`, `modularity.navigation`, `inertia.middleware` | +| `modularity.panel` | `authorization`, `modularity.company.registration`, `modularity.redirector` | + +Spatie Permission middleware (`role`, `permission`, `role_or_permission`) aliases are also registered here. + +## Related + +- [Middleware](/system-reference/backend/http/middleware/overview) — full middleware class reference +- [HostRouting](./host-routing) — uses `hostable` alias registered here diff --git a/docs/src/pages/system-reference/backend/support/modularity-vite.md b/docs/src/pages/system-reference/backend/support/modularity-vite.md new file mode 100644 index 000000000..6fc959089 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/modularity-vite.md @@ -0,0 +1,48 @@ +--- +sidebarPos: 10 +sidebarTitle: ModularityVite +--- + +# ModularityVite + +`Unusualify\Modularity\Support\ModularityVite` + +Extends Laravel's `Illuminate\Foundation\Vite` to point at Modularous own asset manifest instead of the host application's default `public/build/manifest.json`. Used internally by the `@modularityVite` Blade directive and the `Assets` service. + +## Defaults + +| Property | Value | +|----------|-------| +| `$buildDirectory` | `vendor/modularity` | +| `$manifestFilename` | `modularity-manifest.json` | +| `$integrityKey` | `integrity` | + +The resolved asset path is therefore `public/vendor/modularity/<hashed-file>`. + +## Behaviour + +When the Vite dev server is running (`hot` file present), `__invoke()` injects the standard `@vite/client` and `@vite-plugin-svg-spritemap/client` scripts followed by each entrypoint as a hot-reload module tag. + +In production mode, `__invoke()` reads `modularity-manifest.json`, emits `<link rel="modulepreload">` tags for all chunks, and then the actual `<script type="module">` and `<link rel="stylesheet">` tags for each entrypoint. + +## Usage + +Consume via the `Assets` service or the `@modularityVite` directive — do not instantiate directly: + +```blade +{{-- In your layout --}} +@modularityVite(['resources/js/app.js', 'resources/css/app.css']) +``` + +Or in PHP: + +```php +use Unusualify\Modularity\Services\Assets; + +echo app(Assets::class)->vite(['resources/js/app.js']); +``` + +## Related + +- [Assets service](/system-reference/backend/services/assets) — higher-level facade over `ModularityVite` +- [Assets commands](/guide/console/assets/overview) — `modularity:assets:build` / `modularity:assets:dev` diff --git a/docs/src/pages/system-reference/backend/support/overview.md b/docs/src/pages/system-reference/backend/support/overview.md new file mode 100644 index 000000000..9a1b4c6f3 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/overview.md @@ -0,0 +1,45 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +sidebarGroupTitle: Support Classes +--- + +# Support Classes + +The `src/Support/` directory contains internal utility classes that power scaffolding, routing, asset compilation, coverage analysis, and file manipulation. They are not meant to be extended directly by module authors but understanding them helps when debugging or contributing to Modularous internals. + +## Class Reference + +| Class | Namespace | Purpose | +|-------|-----------|---------| +| [CommandDiscovery](./command-discovery) | `Support` | Scans glob paths and returns instantiable `Command` FQCNs | +| [CoverageAnalyzer](./coverage-analyzer) | `Support` | Parses Clover XML and reports per-method coverage | +| [FileLoader](./file-loader) | `Support` | Extends Laravel's translation `FileLoader` with multi-path support | +| [Finder](./finder) | `Support` | Resolves model and repository classes by table or route name | +| [HostRouting / HostRouteRegistrar](./host-routing) | `Support` | Fluent API for registering multi-tenant host-based route groups | +| [ModularityRoutes](./modularity-routes) | `Support` | Defines route group options and registers all middleware aliases | +| [ModularityVite](./modularity-vite) | `Support` | Extends Laravel `Vite` for Modularous asset manifest | +| [RegexReplacement](./regex-replacement) | `Support` | Batch regex find-and-replace across a directory tree | +| [Decomposers](./decomposers) | `Support\Decomposers` | Parse schema/relation/validation strings into arrays for generators | +| [Migrations\SchemaParser](./migrations-schema-parser) | `Support\Migrations` | Renders migration `$table->…` PHP from a schema definition string | + +## Sub-namespaces + +``` +src/Support/ +├── CommandDiscovery.php +├── CoverageAnalyzer.php +├── FileLoader.php +├── Finder.php +├── HostRouteRegistrar.php +├── HostRouting.php +├── ModularityRoutes.php +├── ModularityVite.php +├── RegexReplacement.php +├── Decomposers/ +│ ├── ModelRelationParser.php +│ ├── SchemaParser.php +│ └── ValidatorParser.php +└── Migrations/ + └── SchemaParser.php +``` diff --git a/docs/src/pages/system-reference/backend/support/regex-replacement.md b/docs/src/pages/system-reference/backend/support/regex-replacement.md new file mode 100644 index 000000000..11e037d00 --- /dev/null +++ b/docs/src/pages/system-reference/backend/support/regex-replacement.md @@ -0,0 +1,80 @@ +--- +sidebarPos: 11 +sidebarTitle: RegexReplacement +--- + +# RegexReplacement + +`Unusualify\Modularity\Support\RegexReplacement` + +Recursively walks a directory tree and applies a `preg_replace()` substitution to every file that matches a glob-style directory pattern. Supports dry-run preview, verbosity levels, and vendor/node_modules safety guards. + +## Constructor + +```php +new RegexReplacement( + string $path, // root directory to walk + string $pattern, // PCRE regex to find + string $data, // replacement string + string $directory_pattern = '**/*.php', // glob pattern for file selection + bool $quiet = false, + mixed $verbose = null, + bool $test = false, // dry-run mode +) +``` + +## Configuration Methods + +| Method | Description | +|--------|-------------| +| `setPath(string $path)` | Change the root directory | +| `setPattern(string $pattern)` | Change the PCRE search pattern | +| `setData(string $data)` | Change the replacement string | +| `setDirectoryPattern(string $pattern)` | Change the file glob pattern | + +## Running + +### `run(): bool` + +Walk the directory tree, collect matching files, and apply `preg_replace($pattern, $data, $content)` to each one. + +In **dry-run mode** (`$test = true`) no files are written. Instead, `displayPatternMatches()` is called for each file to print a coloured preview. + +**Safety guards:** + +- Throws `\Exception` if `$path` is empty or `/`. +- Skips files inside `vendor/` or `node_modules/` unless the base `$path` is itself inside one of those directories. + +### `replacePatternFile(string $file): bool` + +Apply the replacement to a single file. Skipped if `pretending()` is true. + +### `displayPatternMatches(string $file): void` + +Print a coloured diff of matched lines to stdout. Controlled by verbosity level: + +- **Quiet** — no output. +- **Normal** — file name only. +- **Verbose** (`-v`) — file name + matched lines with context. +- **Very Verbose** (`-vv`) — file name + unified diff of original vs. replaced lines. + +## Example + +```php +use Unusualify\Modularity\Support\RegexReplacement; + +// Replace all occurrences of the old namespace in PHP files +$replacement = new RegexReplacement( + path: app_path(), + pattern: '/Acme\\\\OldPackage/', + data: 'Acme\\\\NewPackage', + directory_pattern: '**/*.php', + test: true, // preview first +); + +$replacement->run(); +``` + +## Related + +- [modularity:update:laravel:configs](/guide/console/update/update-laravel-configs) — uses `RegexReplacement` internally to patch config files diff --git a/docs/src/pages/system-reference/config.md b/docs/src/pages/system-reference/config.md index e5908557f..d35eac6da 100644 --- a/docs/src/pages/system-reference/config.md +++ b/docs/src/pages/system-reference/config.md @@ -5,7 +5,7 @@ sidebarTitle: Config # Configuration System -Modularity uses a layered configuration system. Understanding the layers helps when customizing or debugging. +Modularous uses a layered configuration system. Understanding the layers helps when customizing or debugging. ## Configuration Layers @@ -57,7 +57,7 @@ Core package settings: app_url, admin paths, theme, enabled features, etc. **Config**: `modularity.currency_provider` **Env**: `MODULARITY_CURRENCY_PROVIDER` -Optional FQCN of a class implementing `CurrencyProviderInterface`. When null, Modularity uses `SystemPricingCurrencyProvider` if the SystemPricing module is present, else `NullCurrencyProvider`. +Optional FQCN of a class implementing `CurrencyProviderInterface`. When null, Modularous uses `SystemPricingCurrencyProvider` if the SystemPricing module is present, else `NullCurrencyProvider`. ## Paths diff --git a/docs/src/pages/system-reference/entities.md b/docs/src/pages/system-reference/entities.md index 76ef0b002..8d0a278ca 100644 --- a/docs/src/pages/system-reference/entities.md +++ b/docs/src/pages/system-reference/entities.md @@ -5,18 +5,48 @@ sidebarTitle: Entities # Entities -Modularity entities (models) use traits for feature composition. All models extend `Unusualify\Modularity\Models\Model`. +Modularous entities (models) use traits for feature composition. All models extend `Unusualify\Modularity\Entities\Model`. + +For detailed documentation on each model, see the [Entities reference](/system-reference/backend/entities/overview). ## Base Classes | Class | Purpose | |-------|---------| -| **Model** | Base Eloquent model | -| **Singleton** | Singleton pattern for single-record models | +| [**Model**](/system-reference/backend/entities/model) | Base Eloquent model — soft-deletes, tagging, caching, presenter | +| [**Revision**](/system-reference/backend/entities/revision) | Abstract base for revision-tracking models | +| [**Singleton**](/system-reference/backend/entities/singleton) | Singleton pattern for single-record models | ## Core Models -User, UserOauth, Profile, Company, Setting, Tag, Tagged, Media, File, Filepond, TemporaryFilepond, Block, Repeater, RelatedItem, Revision, Process, ProcessHistory, Chat, ChatMessage, Assignment, Authorization, CreatorRecord, Feature, State, Stateable, Spread +| Model | Purpose | +|-------|---------| +| [User](/system-reference/backend/entities/user) | Authenticatable user with roles, OAuth, API tokens | +| [UserOauth](/system-reference/backend/entities/user-oauth) | OAuth provider link record | +| [Profile](/system-reference/backend/entities/profile) | Extended user profile data | +| [Company](/system-reference/backend/entities/company) | Organisation/company record with billing info | +| [File](/system-reference/backend/entities/file) | Uploaded file record (non-image) | +| [Media](/system-reference/backend/entities/media) | Image record with dimensions, alt text, captions | +| [Filepond](/system-reference/backend/entities/filepond) | Permanent Filepond upload record | +| [TemporaryFilepond](/system-reference/backend/entities/temporary-filepond) | Temporary upload before form submission | +| [Block](/system-reference/backend/entities/block) | Content block with nested children | +| [Repeater](/system-reference/backend/entities/repeater) | Repeatable content via morph relation | +| [Tag](/system-reference/backend/entities/tag) | Tag with locale support | +| [Tagged](/system-reference/backend/entities/tagged) | Taggable pivot record | +| [Process](/system-reference/backend/entities/process) | State-machine workflow instance | +| [ProcessHistory](/system-reference/backend/entities/process-history) | Process status change audit trail | +| [Assignment](/system-reference/backend/entities/assignment) | Task assignment with status and due dates | +| [Authorization](/system-reference/backend/entities/authorization) | Authorizable relationship pivot | +| [CreatorRecord](/system-reference/backend/entities/creator-record) | Creator tracking record | +| [State](/system-reference/backend/entities/state) | Translatable state definition | +| [Stateable](/system-reference/backend/entities/stateable) | Morph pivot linking state to model | +| [Spread](/system-reference/backend/entities/spread) | Dynamic JSON data via morph relation | +| [Setting](/system-reference/backend/entities/setting) | Key-value settings with translations | +| [Chat](/system-reference/backend/entities/chat) | Chat room attached via morph relation | +| [ChatMessage](/system-reference/backend/entities/chat-message) | Individual chat message | +| [Feature](/system-reference/backend/entities/feature) | Featured/starred content for buckets | +| [RelatedItem](/system-reference/backend/entities/related-item) | Polymorphic related content pivot | +| [NestedsetCollection](/system-reference/backend/entities/nestedset-collection) | Extended nested-set tree collection | ## Entity Traits diff --git a/docs/src/pages/system-reference/features.md b/docs/src/pages/system-reference/features.md index fe3a6c030..3d231949c 100644 --- a/docs/src/pages/system-reference/features.md +++ b/docs/src/pages/system-reference/features.md @@ -5,7 +5,7 @@ sidebarTitle: Features Pattern # Features Pattern -Modularity features use a **triple pattern**: Entity trait + Repository trait + Hydrate. Understanding this pattern helps when adding or customizing features. +Modularous features use a **triple pattern**: Entity trait + Repository trait + Hydrate. Understanding this pattern helps when adding or customizing features. ## Pattern Overview @@ -79,7 +79,7 @@ Some features compose others: ## See Also -- [Module Features Overview](/guide/module-features/) — Feature matrix and quick reference +- [Module Features Overview](/guide/module-features/overview) — Feature matrix and quick reference - [Hydrates](/system-reference/hydrates) — Schema transformation - [Repositories](/system-reference/repositories) — Lifecycle and traits - [Entities](/system-reference/entities) — Entity traits list diff --git a/docs/src/pages/system-reference/frontend/composables/overview.md b/docs/src/pages/system-reference/frontend/composables/overview.md new file mode 100644 index 000000000..279600a5d --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/overview.md @@ -0,0 +1,268 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +outline: deep +--- + +# Vue Hooks + +Modularous ships 38 composable hooks under `vue/src/js/hooks/`. They are the primary building blocks for form inputs, table behaviour, UI state, and media management. + +All hooks are exported from `@/hooks/` (the alias for `vue/src/js/hooks/index.js`). + +```js +import { useAlert, useAuthorization, useModal } from '@/hooks' +``` + +## Getting Started + +Every hook in Modularous follows the same high-level shape: + +```js +const { state, methods } = useXxx(props, emit?) +``` + +- **Inputs**: either component `props` (reactive), a plain options object, or `(props, emit)` for hooks that forward `v-model` updates. +- **Output**: a plain object you destructure into reactive state (`ref` / `computed`) and methods. Never `reactive()`-wrapped — destructure freely without losing reactivity. +- **Lifecycle**: hooks that touch the DOM, the store, or globals (modals, alerts, media library) are safe to call inside `setup()`; they internally handle `onMounted` / `onUnmounted`. + +### Minimal component + +```vue +<script setup> +import { useModal, useAlert } from '@/hooks' + +const { isOpen, open, close } = useModal() +const { success, error } = useAlert() + +async function onSubmit() { + try { + await saveRecord() + success('Saved') + close() + } catch (e) { + error(e.message) + } +} +</script> + +<template> + <v-btn @click="open">Edit</v-btn> + <v-dialog v-model="isOpen">...</v-dialog> +</template> +``` + +## Composition Patterns + +Hooks are small on purpose so they can be layered. The most common compositions follow. + +### Forms: `useForm` + `useInput` + `useValidation` + +`useForm` owns submit, validation, and schema/model sync. Individual inputs use `useInput` to read their schema slice, and `useValidation` to bind rules. + +```js +// Form.vue (orchestrator) +const { formData, submit, isSubmitting, errors } = useForm(props, emit) + +// Inside any input component +const { modelValue, boundProps } = useInput(props, emit) +const { rules } = useValidation(props.schema) +``` + +See [useForm](./use-form), [useInput](./use-input), [useValidation](./use-validation). + +### Inputs that fetch: `useInput` + `useInputFetch` + +Select / autocomplete inputs with remote data combine both: + +```js +const { modelValue, boundProps } = useInput(props, emit) +const { items, loading, search } = useInputFetch(props.schema) +``` + +### Tables: `useTable` as a super-composable + +`useTable` already wires the 11 sub-hooks. You rarely compose sub-hooks yourself — read them to **extend** a behaviour, not to rebuild it. + +```js +// Table.vue +const table = useTable(props) +// table.headers, table.filters, table.items, table.editItem, ... +``` + +Only reach for `useTableHeaders`, `useTableFilters`, etc. when building a custom table UI that reuses part of the behaviour. + +### Media uploads: input + hook pair + +| Input component | Hook | +|-----------------|------| +| `VInputImage` | `useImage` — Media library selection | +| `VInputFile` | `useFile` — Media library selection | +| `VInputFilepond` | `useFilepond` — direct FilePond upload | + +```js +const { open, selected } = useMediaLibrary({ type: 'image' }) +const { modelValue, processFile } = useFilepond(props, emit) +``` + +### Global UI services + +`useAlert`, `useDynamicModal`, `useSidebar` are **singletons** backed by Vuex / provide-inject. Call them from anywhere without wiring: + +```js +const { info, success, error } = useAlert() +const { open: openModal } = useDynamicModal() +``` + +### App state + +`useConfig`, `useUser`, `useLocale`, `useAuthorization`, `useCache` read from the Vuex store. They are the idiomatic way to access global state — avoid reading `window.__*` or `store.state.*` directly. + +```js +const { user, can } = useUser() +const { t, locale } = useLocale() +const { config } = useConfig() + +if (can('edit', 'posts')) { /* ... */ } +``` + +## Conventions + +### Naming + +| Pattern | Meaning | +|---------|---------| +| `useXxx` | Main composable (imported from `@/hooks`) | +| `makeXxxProps` | Vuetify `propsFactory` export — reuse props on a component | +| `useXxx/useYyy` | Sub-hook (in subfolder, e.g. `hooks/table/useTableHeaders`) | + +### Props factories + +Most hooks that consume props also export a `makeXxxProps` factory. Use it when building a component that should accept the same props: + +```js +import { makeModalProps } from '@/hooks/useModal' + +export default defineComponent({ + props: { + ...makeModalProps(), + title: String, + }, + setup(props, ctx) { + const modal = useModal(props) + // ... + }, +}) +``` + +### Return shape + +Hooks return plain objects. Do **not** wrap them in `reactive()` at the call site — individual `ref` / `computed` values stay reactive when destructured. + +```js +// Correct +const { isOpen, open, close } = useModal() + +// Wrong — loses reactivity on destructure +const modal = reactive(useModal()) +``` + +### When a hook isn't the answer + +- For **one-off local state**, plain `ref` is fine; don't invent a hook for two lines. +- For **pure utilities** (formatters, validators), put them in `vue/src/js/utils/` — hooks are for stateful or reactive behaviour. + +## Full Hook Reference + +| Hook | File | Purpose | +|------|------|---------| +| [useActiveTableItem](/system-reference/frontend/composables/use-active-table-item) | `useActiveTableItem.js` | Active row / detail-panel state in tables | +| [useAlert](/system-reference/frontend/composables/use-alert) | `useAlert.js` | Trigger global alert notifications | +| [useAuthorization](/system-reference/frontend/composables/use-authorization) | `useAuthorization.js` | Permission and role checks in Vue | +| [useCache](/system-reference/frontend/composables/use-cache) | `useCache.js` | Client-side key-value cache via Vuex | +| [useCastAttributes](/system-reference/frontend/composables/use-cast-attributes) | `useCastAttributes.js` | Dynamic `$notation` attribute interpolation | +| [useCurrency](/system-reference/frontend/composables/use-currency) | `useCurrency.js` | Currency value helpers | +| [useCurrencyNumber](/system-reference/frontend/composables/use-currency-number) | `useCurrencyNumber.js` | Number formatting with currency | +| [useConfig](/system-reference/frontend/composables/use-config) | `useConfig.js` | Access app config from Vuex | +| [useDraggable](/system-reference/frontend/composables/use-draggable) | `useDraggable.js` | Drag-and-drop (Sortable.js) props and state | +| [useDynamicModal](/system-reference/frontend/composables/use-dynamic-modal) | `useDynamicModal.js` | Inject-based global modal service | +| [useFile](/system-reference/frontend/composables/use-file) | `useFile.js` | File media-library input state | +| [useFilepond](/system-reference/frontend/composables/use-filepond) | `useFilepond.js` | FilePond upload props and validation rules | +| [useForm](/system-reference/frontend/composables/use-form) | `useForm.js` | Top-level form state, submit, validation | +| [useFormBase](/system-reference/frontend/composables/use-form-base) | `useFormBase.js` | FormBase flattening and field iteration | +| [useFormBaseLogic](/system-reference/frontend/composables/use-form-base-logic) | `useFormBaseLogic.js` | FormBase rendering logic | +| [useFormatter](/system-reference/frontend/composables/use-formatter) | `useFormatter.js` | Table column value formatters | +| [useImage](/system-reference/frontend/composables/use-image) | `useImage.js` | Image media-library input state | +| [useInertiaRequests](/system-reference/frontend/composables/use-inertia-requests) | `useInertiaRequests.js` | Inertia.js in-flight request state | +| [useInput](/system-reference/frontend/composables/use-input) | `useInput.js` | Base input state, `modelValue`, schema binding | +| [useInputFetch](/system-reference/frontend/composables/use-input-fetch) | `useInputFetch.js` | Paginated remote data fetch for select inputs | +| [useInputHandlers](/system-reference/frontend/composables/use-input-handlers) | `useInputHandlers.js` | Slot-driven input click handlers | +| [useItemActions](/system-reference/frontend/composables/use-item-actions) | `useItemActions.js` | Form action buttons (request / modal / download / blank) | +| [useLocale](/system-reference/frontend/composables/use-locale) | `useLocale.js` | Active locale helpers | +| [useMediaItems](/system-reference/frontend/composables/use-media-items) | `useMediaItems.js` | Selected media item list management | +| [useMediaLibrary](/system-reference/frontend/composables/use-media-library) | `useMediaLibrary.js` | Open/close media library modal | +| [useModal](/system-reference/frontend/composables/use-modal) | `useModal.js` | Modal open/close, width, fullscreen state | +| [useModelValue](/system-reference/frontend/composables/use-model-value) | `useModelValue.js` | `v-model` two-way binding helper | +| [useModule](/system-reference/frontend/composables/use-module) | `useModule.js` | Module name translation and metadata | +| [useNavigationLayout](/system-reference/frontend/composables/use-navigation-layout) | `useNavigationLayout.js` | Topbar / bottom-nav config merging | +| [useRandKey](/system-reference/frontend/composables/use-rand-key) | `useRandKey.js` | Unique component instance key | +| [useRepeater](/system-reference/frontend/composables/use-repeater) | `useRepeater.js` | Repeater block state, add / delete / duplicate | +| [useRoot](/system-reference/frontend/composables/use-root) | `useRoot.js` | Vuetify / root instance access | +| [useSidebar](/system-reference/frontend/composables/use-sidebar) | `useSidebar.js` | Sidebar open/close, rail, resize | +| [useSvg](/system-reference/frontend/composables/use-svg) | `useSvg.js` | SVG symbol existence and locale lookup | +| [useTable](/system-reference/frontend/composables/use-table) | `useTable.js` | Main data-table composable | +| [useUser](/system-reference/frontend/composables/use-user) | `useUser.js` | Authenticated user state and authorization proxy | +| [useValidation](/system-reference/frontend/composables/use-validation) | `useValidation.js` | Validation rules and rule generator | + +## Props Factories + +Many hooks export a `makeXxxProps` factory built with Vuetify's `propsFactory`. Use these when building components that accept the same props as a hook: + +```js +import { makeModalProps } from '@/hooks/useModal' +import { makeRepeaterProps } from '@/hooks/useRepeater' +import { makeFilepondProps } from '@/hooks/useFilepond' +``` + +## Hook Layers + +``` +App state useConfig · useUser · useLocale · useAuthorization · useCache +UI chrome useSidebar · useNavigationLayout · useModal · useDynamicModal +Notifications useAlert +Form useForm · useFormBase · useInput · useModelValue · useValidation +Inputs useFile · useImage · useFilepond · useRepeater · useInputFetch + useInputHandlers · useDraggable +Table useTable · useActiveTableItem · useItemActions · useFormatter +Utilities useCastAttributes · useModule · useRandKey · useRoot · useSvg + useInertiaRequests +``` + +## Table Sub-hooks + +`useTable` is composed from 11 internal sub-hooks. See the [Table Sub-hooks Overview](/system-reference/frontend/composables/table/overview) for details. + +| Sub-hook | Purpose | +|----------|---------| +| [useTableActions](/system-reference/frontend/composables/table/use-table-actions) | Toolbar / bulk action props | +| [useTableFilters](/system-reference/frontend/composables/table/use-table-filters) | Search, status tabs, advanced filters | +| [useTableForms](/system-reference/frontend/composables/table/use-table-forms) | Create/edit form state | +| [useTableGroup](/system-reference/frontend/composables/table/use-table-group) | Client-side column grouping | +| [useTableHeaders](/system-reference/frontend/composables/table/use-table-headers) | Column visibility and localStorage | +| [useTableItem](/system-reference/frontend/composables/table/use-table-item) | Edited item and soft-delete detection | +| [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) | Per-row action dispatch | +| [useTableIterator](/system-reference/frontend/composables/table/use-table-iterator) | Iterator (card/list) layout actions | +| [useTableModals](/system-reference/frontend/composables/table/use-table-modals) | Delete / custom / show modals | +| [useTableNames](/system-reference/frontend/composables/table/use-table-names) | i18n titles and dialog text | +| [useTableState](/system-reference/frontend/composables/table/use-table-state) | URL/localStorage state persistence | + +## Utility Sub-hooks + +Four small utility composables are available under `vue/src/js/hooks/utils/`. See the [Utils Overview](/system-reference/frontend/composables/utils/overview) for details. + +| Sub-hook | Purpose | +|----------|---------| +| [useBadge](/system-reference/frontend/composables/utils/use-badge) | Badge visibility and props for action buttons | +| [useGenerate](/system-reference/frontend/composables/utils/use-generate) | Button prop generation with Inertia-aware href handling | +| [usePagination](/system-reference/frontend/composables/utils/use-pagination) | Infinite-scroll / load-more pagination state | +| [useSelect](/system-reference/frontend/composables/utils/use-select) | Select input prop definitions (`makeSelectProps`) | diff --git a/docs/src/pages/system-reference/frontend/composables/table/overview.md b/docs/src/pages/system-reference/frontend/composables/table/overview.md new file mode 100644 index 000000000..d127d58ad --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/overview.md @@ -0,0 +1,24 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Table Sub-hooks + +`useTable` is composed from 11 focused sub-hooks that each own a specific slice of the table's behavior. They live under `vue/src/js/hooks/table/` and are exported from `@/hooks/table`. + +You rarely need to use these directly — `useTable` wires them together. Read these pages when you need to extend or replace a specific behavior. + +| Sub-hook | File | Purpose | +|----------|------|---------| +| [useTableActions](/system-reference/frontend/composables/table/use-table-actions) | `useTableActions.js` | Toolbar / bulk action props | +| [useTableFilters](/system-reference/frontend/composables/table/use-table-filters) | `useTableFilters.js` | Search, status tabs, advanced filters | +| [useTableForms](/system-reference/frontend/composables/table/use-table-forms) | `useTableForms.js` | Create/edit form state | +| [useTableGroup](/system-reference/frontend/composables/table/use-table-group) | `useTableGroup.js` | Client-side column grouping | +| [useTableHeaders](/system-reference/frontend/composables/table/use-table-headers) | `useTableHeaders.js` | Column visibility and localStorage | +| [useTableItem](/system-reference/frontend/composables/table/use-table-item) | `useTableItem.js` | Edited item and soft-delete detection | +| [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) | `useTableItemActions.js` | Per-row action dispatch | +| [useTableIterator](/system-reference/frontend/composables/table/use-table-iterator) | `useTableIterator.js` | Iterator (card/list) layout actions | +| [useTableModals](/system-reference/frontend/composables/table/use-table-modals) | `useTableModals.js` | Delete / custom / show modals | +| [useTableNames](/system-reference/frontend/composables/table/use-table-names) | `useTableNames.js` | i18n titles and dialog text | +| [useTableState](/system-reference/frontend/composables/table/use-table-state) | `useTableState.js` | URL/localStorage state persistence | diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-actions.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-actions.md new file mode 100644 index 000000000..12f415f22 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-actions.md @@ -0,0 +1,41 @@ +--- +sidebarTitle: useTableActions +--- + +# useTableActions + +Defines props for table-level toolbar actions (bulk operations, custom toolbar buttons). + +**File:** `vue/src/js/hooks/table/useTableActions.js` + +--- + +## Props Factory + +```js +import { makeTableActionsProps } from '@/hooks/table/useTableActions' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `actionsPosition` | `String` | `'top'` | Where to render toolbar actions: `'top'` or `'bottom'` | +| `actions` | `Array` | `[]` | Action button definitions for the table toolbar | + +## Usage + +```js +import useTableActions, { makeTableActionsProps } from '@/hooks/table/useTableActions' + +const props = defineProps(makeTableActionsProps()) +useTableActions(props, context) +``` + +## Notes + +- This hook currently defines props and reserves the extension point for future toolbar-action logic. +- Per-row actions are handled by [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions). + +## See Also + +- [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) — per-row action handlers +- [useTable](/system-reference/frontend/composables/use-table) — orchestrating composable diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-filters.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-filters.md new file mode 100644 index 000000000..b9d8498eb --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-filters.md @@ -0,0 +1,94 @@ +--- +sidebarTitle: useTableFilters +--- + +# useTableFilters + +Manages the table's search field, status-tab filter, and advanced filter panel — including active counts and reset/clear operations. + +**File:** `vue/src/js/hooks/table/useTableFilters.js` + +--- + +## Props Factory + +```js +import { makeTableFiltersProps } from '@/hooks/table/useTableFilters' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `hideSearchField` | `Boolean` | `false` | Hide the search input | +| `navActive` | `String` | `'all'` | Initial active status-tab slug | +| `filterBtnOptions` | `Object` | `{}` | Options for the filter button component | +| `searchInitialValue` | `String` | `''` | Initial search string | +| `filterList` | `Array` | `[]` | Status tab definitions `[{ slug, name, number }]` | +| `filterListAdvanced` | `Object` | `{}` | Advanced filter panel definitions by category | +| `hideFilters` | `Boolean` | `false` | Hide the status-tab filter bar | +| `hideAdvancedFilters` | `Boolean` | `false` | Hide the advanced filter button | +| `showMobileHeaders` | `Boolean` | `false` | Show column headers on mobile | + +## Usage + +```js +import useTableFilters, { makeTableFiltersProps } from '@/hooks/table/useTableFilters' + +const { + search, + searchModel, + activeFilterSlug, + activeFilter, + mainFilters, + advancedFilters, + activeAdvancedFilters, + activeFilterCount, + setSearchValue, + setFilterSlug, + clearAdvancedFilter, +} = useTableFilters(props) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `search` | `Ref<String>` | Committed search value (triggers `loadItems`) | +| `searchModel` | `Ref<String>` | Bound to the search `<v-text-field>` (not yet committed) | +| `activeFilterSlug` | `Ref<String>` | Currently selected status-tab slug | +| `activeFilter` | `ComputedRef<Object>` | The full filter object for the active slug | +| `mainFilters` | `Ref<Array>` | Status tab list (can be updated by `setMainFilters`) | +| `advancedFilters` | `Ref<Object>` | Advanced filter state by category | +| `activeAdvancedFilters` | `ComputedRef<Object>` | Only the categories/filters that have a non-empty selection | +| `activeFilterCount` | `ComputedRef<Number>` | Total number of active advanced filters | +| `expandedPanels` | `Ref<Array>` | Which advanced filter panels are expanded | +| `advancedFilterMenuOpen` | `Ref<Boolean>` | Whether the advanced filter menu is open | +| `filterBtnTitle` | `ComputedRef<Object>` | Text for the filter button (includes active filter count) | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `setSearchValue` | `(value?) => Boolean` | Commit a new search value; returns `true` if changed | +| `setFilterSlug` | `(slug) => Boolean` | Change the active status tab; returns `true` if changed | +| `setMainFilters` | `(filters) => void` | Replace the status tab list | +| `setAdvancedFilters` | `(filters) => void` | Replace the advanced filter state | +| `clearAdvancedFilter` | `() => void` | Clear all advanced filter selections | +| `resetAdvancedFilter` | `() => void` | Reset all filters (alias for clear with different empty value semantics) | +| `resetCategoryFilters` | `(category) => void` | Reset filters for a single category | +| `removeAdvancedFilter` | `(category, slug) => void` | Remove a single filter | +| `getCategoryLabel` | `(category) => String` | Human-readable label for a filter category | +| `getActiveCategoryFilterCount` | `(category) => Number` | Count of active filters in a category | +| `getFilterLabel` | `(category, slug) => String` | Label for a specific filter | +| `formatFilterValue` | `(category, slug) => String` | Display string for a filter's current value | + +## Notes + +- `search` and `searchModel` are separate: `searchModel` is bound to the input and only commits to `search` when `setSearchValue` is called (e.g. on Enter or blur). This prevents a `loadItems` call on every keystroke. +- Initial state restores from `useTableState.lastParameters` so filter/search survive page refreshes. + +## See Also + +- [useTableState](/system-reference/frontend/composables/table/use-table-state) — URL/localStorage persistence +- [useTable](/system-reference/frontend/composables/use-table) — orchestrating composable diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-forms.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-forms.md new file mode 100644 index 000000000..ae53c3bd9 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-forms.md @@ -0,0 +1,92 @@ +--- +sidebarTitle: useTableForms +--- + +# useTableForms + +Manages the create/edit form panel inside a data table — open/close state, loading, errors, and the custom-form-modal workflow. + +**File:** `vue/src/js/hooks/table/useTableForms.js` + +--- + +## Props Factory + +```js +import { makeTableFormsProps } from '@/hooks/table/useTableForms' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `inputFields` | `Array` | `[]` | Legacy override for the form's field list | +| `formSchema` | `Object` | required | Schema definition for the table's create/edit form | +| `formWidth` | `String\|Number` | `'60%'` | Width of the form modal | +| `createOnModal` | `Boolean` | `true` | Open the create form in a modal | +| `editOnModal` | `Boolean` | `true` | Open the edit form in a modal | +| `embeddedForm` | `Boolean` | `false` | Embed the form inline instead of in a modal | +| `addBtnOptions` | `Object` | `{}` | Props for the "Add" button (e.g. custom text) | +| `noForm` | `Boolean` | `false` | Disable create/edit form entirely | +| `formActions` | `Array\|Object` | `[]` | Extra action buttons in the form footer | + +## Usage + +```js +import useTableForms, { makeTableFormsProps } from '@/hooks/table/useTableForms' + +const { + formActive, + UeForm, + formLoading, + formErrors, + addBtnTitle, + openForm, + closeForm, + createForm, + handleFormSubmission, +} = useTableForms(props, context) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `formActive` | `Ref<Boolean>` | Whether the create/edit form is visible | +| `UeForm` | `Ref` | Template ref to the `<UeForm>` component | +| `customFormModalActive` | `Ref<Boolean>` | Whether the custom action-form modal is open | +| `customFormSchema` | `Ref<Object>` | Schema for the custom action form | +| `customFormModel` | `Ref<Object>` | Model for the custom action form | +| `customFormAttributes` | `Ref<Object>` | Attributes for the custom action form | +| `customFormModalAttributes` | `Ref<Object>` | Modal attributes for the custom action form | + +### Computed + +| Name | Type | Description | +|------|------|-------------| +| `formRef` | `ComputedRef<String>` | Unique DOM ref name for the form | +| `formStyles` | `ComputedRef<Object>` | `{ width: formWidth }` style object | +| `formLoading` | `ComputedRef<Boolean>` | `true` while the form is submitting | +| `formErrors` | `ComputedRef<Object>` | Current validation errors from the Vuex form store | +| `formIsValid` | `ComputedRef<Boolean\|null>` | Form validity state from `UeForm` | +| `addBtnTitle` | `ComputedRef<String>` | Translated "Add {item}" label for the create button | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `openForm` | `() => void` | Open the create/edit form | +| `closeForm` | `() => void` | Close the form and reset the edited item | +| `createForm` | `() => void` | Reset the edited item to defaults and open the form | +| `handleFormSubmission` | `(data) => void` | Called on form submit response — closes form and reloads table on success | + +## Notes + +- Closing the form automatically calls `resetEditedItem()` via a watcher on `formActive`. +- `handleFormSubmission` checks `data.variant === 'success'` before triggering a reload. + +## See Also + +- [useTableItem](/system-reference/frontend/composables/table/use-table-item) — edited item state +- [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) — triggers `openForm` for edit/duplicate +- [useTable](/system-reference/frontend/composables/use-table) — orchestrating composable diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-group.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-group.md new file mode 100644 index 000000000..ae3d82f31 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-group.md @@ -0,0 +1,81 @@ +--- +sidebarTitle: useTableGroup +--- + +# useTableGroup + +Provides client-side column grouping for Vuetify's `v-data-table`. Columns opt-in via `groupable: true`; only one group key can be active at a time. + +**File:** `vue/src/js/hooks/table/useTableGroup.js` + +--- + +## Usage + +```js +import useTableGroup from '@/hooks/table/useTableGroup' + +const { + groupKeys, + hasGroupableColumns, + selectedGroupKey, + isGroupingActive, + toggleGroupByColumn, + clearGroupBy, + groupLabelForKey, +} = useTableGroup(props, optionsRef) +``` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `props` | `Object` | Component props — reads `props.columns` | +| `optionsRef` | `Ref<Object>` | Vuetify data-table `options` ref — `groupBy` is written here | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `groupKeys` | `ComputedRef<Array<String>>` | Keys of all columns with `groupable: true` | +| `hasGroupableColumns` | `ComputedRef<Boolean>` | `true` when at least one column is groupable | +| `hasGroupMenu` | `ComputedRef<Boolean>` | Alias for `hasGroupableColumns` (deprecated) | +| `selectedGroupKey` | `WritableComputedRef<String\|null>` | The currently active group key, or `null`. Set to activate a group. | +| `isGroupingActive` | `ComputedRef<Boolean>` | `true` when any group is applied | +| `isGroupActiveForKey` | `(key) => Boolean` | Returns `true` when `key` is the active group | +| `toggleGroupByColumn` | `(key) => void` | Activate `key` as the group column, or clear it if already active | +| `clearGroupBy` | `() => void` | Remove all grouping | +| `groupLabelForKey` | `(key) => String` | Returns the column `title` for the given key | + +## Column Configuration + +Enable grouping on a column by adding `groupable: true` and optionally `groupOrder`: + +```js +{ + key: 'status', + title: 'Status', + groupable: true, + groupOrder: 'asc' // 'asc' | 'desc', default 'asc' +} +``` + +## Exported Utilities + +| Function | Description | +|----------|-------------| +| `normalizeGroupByConfig(list)` | Normalizes a raw `groupBy` list to `[{ key, order }]` | +| `parseGroupByConfigItem(raw)` | Parses a single group-by entry (string or object) | +| `normalizeGroupOrder(order)` | Returns `'asc'` or `'desc'`, defaulting to `'asc'` | +| `pickFetchRelevantOptions(options)` | Strips `groupBy` (client-only) before building an API request | +| `onlyGroupByChanged(old, new)` | Returns `true` when only `groupBy` changed — used to skip redundant API calls | + +## Notes + +- `groupBy` is client-side only and is never sent to the index API endpoint. +- `onlyGroupByChanged` is used by `useTable`'s options watcher to avoid redundant `loadItems` calls when toggling grouping. + +## See Also + +- [useTableHeaders](/system-reference/frontend/composables/table/use-table-headers) — column definitions +- [useTable](/system-reference/frontend/composables/use-table) — orchestrating composable diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-headers.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-headers.md new file mode 100644 index 000000000..eea2e3e05 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-headers.md @@ -0,0 +1,93 @@ +--- +sidebarTitle: useTableHeaders +--- + +# useTableHeaders + +Manages column visibility with `localStorage` persistence. Columns can be toggled off per-route and the selection survives page refreshes. + +**File:** `vue/src/js/hooks/table/useTableHeaders.js` + +--- + +## Props Factory + +```js +import { makeTableHeadersProps } from '@/hooks/table/useTableHeaders' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `columns` | `Array` | `[]` | Full column definition array | +| `fixedLastColumn` | `Boolean` | `false` | Pin the last column (actions) to the right | +| `hideHeaders` | `Boolean` | `false` | Hide all column headers | +| `headerOptions` | `Array\|Object` | `{}` | Extra props merged into the header row | +| `cellOptions` | `Array\|Object` | `{}` | Extra props merged into each cell | +| `customRow` | `Object` | `{}` | Custom row template definition (hides headers when set) | + +## Usage + +```js +import useTableHeaders, { makeTableHeadersProps } from '@/hooks/table/useTableHeaders' + +const { + headers, + headersModel, + hasSearchableHeader, + selectedHeaders, + hideHeaders, + formattableHeaders, + removeHeader, + applyHeaders, +} = useTableHeaders(props) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `headers` | `Ref<Array>` | Currently visible columns (filtered from `columns`) | +| `headersModel` | `Ref<Array>` | Full column list with `visible` boolean added to each entry (for the column picker UI) | +| `hasSearchableHeader` | `Ref<Boolean>` | `true` when at least one column has `searchable: true` | + +### Computed + +| Name | Type | Description | +|------|------|-------------| +| `selectedHeaders` | `ComputedRef<Array>` | Columns with `visible === true` | +| `hideHeaders` | `ComputedRef<Boolean>` | `true` when `props.hideHeaders` is set or `customRow` is present | +| `formattableHeaders` | `ComputedRef<Array>` | Columns with at least one of: `columnEditable`, `removable`, `searchable`, or `groupable` | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `removeHeader` | `(key: String) => void` | Hide the column with the given key and persist to `localStorage` | +| `applyHeaders` | `() => void` | Apply the current `headersModel` visibility state and persist to `localStorage` | + +## localStorage Key + +Hidden columns are stored per-route under: + +``` +table_unvisible_columns_{window.location.pathname} +``` + +## Column Definition Fields + +| Field | Type | Description | +|-------|------|-------------| +| `key` | `String` | Unique column identifier | +| `title` | `String` | Display header text | +| `searchable` | `Boolean` | Allow per-column search | +| `removable` | `Boolean` | Allow hiding via the column picker | +| `columnEditable` | `Boolean` | Allow inline cell editing | +| `groupable` | `Boolean` | Allow grouping by this column | +| `groupOrder` | `'asc'\|'desc'` | Default sort order when grouped | + +## See Also + +- [useTableGroup](/system-reference/frontend/composables/table/use-table-group) — client-side grouping by column +- [useTable](/system-reference/frontend/composables/use-table) — orchestrating composable diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-item-actions.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-item-actions.md new file mode 100644 index 000000000..696780de2 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-item-actions.md @@ -0,0 +1,78 @@ +--- +sidebarTitle: useTableItemActions +--- + +# useTableItemActions + +Dispatches per-row actions (edit, delete, restore, duplicate, switch, link, bulk, custom form, show data) and computes which actions are visible for a given row. + +**File:** `vue/src/js/hooks/table/useTableItemActions.js` + +--- + +## Props Factory + +```js +import { makeTableItemActionsProps } from '@/hooks/table/useTableItemActions' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `isRowEditing` | `Boolean` | — | Whether the row is currently being inline-edited | +| `hideMobileActions` | `Boolean` | `false` | Hide action buttons on mobile | +| `rowActionsIcon` | `String` | `'mdi-cog-outline'` | Icon for the dropdown trigger button | +| `rowActions` | `Array\|Object` | `[]` | Action definitions for each row | +| `rowActionsType` | `String` | `'inline'` | `'inline'` renders icons; `'dropdown'` renders a menu | +| `iteratorType` | `String` | `''` | Set to `'iterator'` when used in card/list view | + +## Usage + +```js +import useTableItemActions, { makeTableItemActionsProps } from '@/hooks/table/useTableItemActions' + +const { itemAction, itemHasAction, visibleRowActions, actionShowingType, actionEvents } = + useTableItemActions(props, { TableForms, loadItems, TableItem, TableNames }) +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `itemAction` | `(item, action, ...args) => void` | Dispatch an action for a row | +| `itemHasAction` | `(item, action) => Boolean` | Returns `true` when the action should be shown for this item | +| `visibleRowActions` | `ComputedRef<Array>` | Processed action definitions with resolved icons, colors, and component props | +| `actionShowingType` | `ComputedRef<'inline'\|'dropdown'>` | Whether actions render inline or in a dropdown (auto-collapses on small screens) | +| `actionEvents` | `Reactive<Object>` | Shared event bus `{ event, payload, reset() }` consumed by `DataTable` to open dialogs | + +## Built-in Action Names + +| Name | Behavior | +|------|----------| +| `edit` | Opens the form modal with the row's data | +| `delete` / `forceDelete` | Triggers a confirmation dialog then calls `datatableApi.delete` / `forceDelete` | +| `restore` | Triggers `datatableApi.restore` | +| `duplicate` | Strips `id` from the item and opens the create form | +| `switch` | Sends a `PUT` to `endpoints.update` with the toggled key/value | +| `link` | Opens `item.href` or `action.url` (`:id` replaced) in the configured target | +| `activate` | Sets the active row in the table | +| `bulkDelete` / `bulkForceDelete` / `bulkRestore` / `bulkPublish` | Bulk operations via dialog confirmation | + +## Custom Actions + +Any action with a `form` property triggers `handleCustomFormAction`, which loads a custom schema/model into the `useTableForms` custom-form modal. + +Any action with a `show` property triggers `handleShowAction`, which opens the show-data modal with the relevant item data. + +## Permission Checks + +`itemHasAction` uses `can(action.name, permissionName)` for built-in actions. It also evaluates `action.conditions` and `action.userConditions` against the row item and the user profile respectively. + +## Responsive Collapse + +`actionShowingType` automatically switches to `'dropdown'` on smaller viewports when the number of actions exceeds the breakpoint threshold (2+ actions on mobile, 3+ on tablet, etc.). + +## See Also + +- [useTableForms](/system-reference/frontend/composables/table/use-table-forms) — form modal opened by edit/duplicate +- [useTableItem](/system-reference/frontend/composables/table/use-table-item) — edited item state +- [useAuthorization](/system-reference/frontend/composables/use-authorization) — permission checks diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-item.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-item.md new file mode 100644 index 000000000..d0c061aca --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-item.md @@ -0,0 +1,57 @@ +--- +sidebarTitle: useTableItem +--- + +# useTableItem + +Holds the currently edited row and provides soft-delete detection helpers. + +**File:** `vue/src/js/hooks/table/useTableItem.js` + +--- + +## Usage + +```js +import useTableItem from '@/hooks/table/useTableItem' + +const { + editedItem, + isSoftDeletableItem, + itemIsDeleted, + setEditedItem, + resetEditedItem, + isSoftDeletable, + isDeleted, +} = useTableItem(props, context) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `editedItem` | `Ref<Object>` | The row currently open in the form. Initialized from `props.modelValue` or a blank model derived from `formSchema`. | +| `isSoftDeletableItem` | `ComputedRef<Boolean>` | `true` when `editedItem.deleted_at` is set (soft-deleted) | +| `itemIsDeleted` | `ComputedRef<Boolean>` | Alias for `isSoftDeletableItem` | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `setEditedItem` | `(item: Object) => void` | Replace `editedItem` with a shallow copy of `item` | +| `resetEditedItem` | `() => void` | Reset `editedItem` to a blank model (runs in `nextTick`) | +| `isSoftDeletable` | `(item: Object) => Boolean` | Returns `true` when `item.deleted_at` is truthy | +| `isDeleted` | `(item: Object) => Boolean` | Alias for `isSoftDeletable` | + +## Notes + +- `resetEditedItem` uses `nextTick` to avoid clearing the form before the close animation finishes. +- Soft-delete detection drives which delete action is shown (`delete` vs `forceDelete`) and which dialog text is used. + +## See Also + +- [useTableForms](/system-reference/frontend/composables/table/use-table-forms) — uses `editedItem` for the form model +- [useTableNames](/system-reference/frontend/composables/table/use-table-names) — uses soft-delete state for dialog text +- [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) — triggers `setEditedItem` before actions diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-iterator.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-iterator.md new file mode 100644 index 000000000..f182f7666 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-iterator.md @@ -0,0 +1,65 @@ +--- +sidebarTitle: useTableIterator +--- + +# useTableIterator + +Composable for card/list (iterator) layout components. Wires up item actions, formatting, and the header key map for a single row item in a non-table view. + +**File:** `vue/src/js/hooks/table/useTableIterator.js` + +--- + +## Props Factory + +```js +import { makeTableIteratorProps } from '@/hooks/table/useTableIterator' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `name` | `String` | `''` | Module name used for i18n and permissions | +| `titlePrefix` | `String` | `''` | Prefix added before the item title | +| `titleKey` | `String` | `'name'` | The item property used as the display title | +| `item` | `Object` | `{}` | The row data object | +| `headers` | `Object` | `{}` | Column definitions keyed by column key | +| `iteratorOptions` | `Object` | `{}` | Additional iterator display options | +| `rowActions` | `Array` | `[]` | Per-item action definitions | + +## Emits + +| Event | Payload | Description | +|-------|---------|-------------| +| `click-action` | `(item, action)` | Forwarded from `itemAction` — parent handles the action | +| `edit-item` | `(item)` | Forwarded from `editItem` — parent handles the edit | + +## Usage + +```js +import useTableIterator, { makeTableIteratorProps } from '@/hooks/table/useTableIterator' + +const props = defineProps(makeTableIteratorProps()) +const emit = defineEmits(['click-action', 'edit-item']) + +const { headersWithKeys, itemHasAction, formatValue, id } = useTableIterator(props, { emit }) +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `id` | `String` | Unique instance ID for the iterator | +| `headersWithKeys` | `ComputedRef<Object>` | Headers object re-keyed by `header.key` for fast lookup | +| `itemHasAction` | `(action) => Boolean` | Whether an action should be shown for the current `props.item` | +| `formatValue` | `(value, header) => any` | Format a cell value using the column's formatter | + +## Notes + +- `itemAction` and `editItem` emit events upward rather than handling them directly — the parent `DataTable` or iterator container owns the action logic. +- `useTableIterator` is used by card-view and list-view layout components that render each row outside of `v-data-table`. + +## See Also + +- [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) — action dispatch +- [useFormatter](/system-reference/frontend/composables/use-formatter) — value formatting +- [useTable](/system-reference/frontend/composables/use-table) — orchestrating composable diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-modals.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-modals.md new file mode 100644 index 000000000..5ea43ae9c --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-modals.md @@ -0,0 +1,97 @@ +--- +sidebarTitle: useTableModals +--- + +# useTableModals + +Manages the three modal types used by a data table: the confirmation dialog, a custom content modal, and the show-data (detail view) modal. + +**File:** `vue/src/js/hooks/table/useTableModals.js` + +--- + +## Props Factory + +```js +import { makeTableModalsProps } from '@/hooks/table/useTableModals' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `openCustomModal` | `Boolean` | `false` | Initialize with the custom modal open | + +## Usage + +```js +import useTableModals, { makeTableModalsProps } from '@/hooks/table/useTableModals' + +const { + modals, + customModalActive, + actionDialogQuestion, + openCustomModal, + closeCustomModal, + setModalType, +} = useTableModals(props, context) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `modals` | `Ref<Object>` | Map of modal objects keyed by type: `'dialog'`, `'custom'`, `'show'` | +| `deleteModalActive` | `Ref<Boolean>` | Legacy flag for the delete confirmation modal | +| `customModalActive` | `Ref<Boolean>` | Whether the custom content modal is visible | +| `actionModalActive` | `Ref<Boolean>` | Whether the action-result modal is visible | +| `selectedAction` | `Ref<Object\|null>` | The action that triggered the current modal | +| `activeModal` | `ComputedRef<Object>` | The modal object for the current `activeModalType` | + +### Computed + +| Name | Type | Description | +|------|------|-------------| +| `actionDialogQuestion` | `ComputedRef<String>` | Translated confirmation question for the active action | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `openCustomModal` | `() => void` | Open the custom modal | +| `closeCustomModal` | `() => void` | Close the custom modal | +| `setModalType` | `(type: String) => void` | Switch the active modal type | +| `bulkAction` | `(action) => void` | Execute a bulk action via `datatableApi` | + +## Modal Object API + +Each entry in `modals` exposes a consistent interface: + +```js +modals.value.dialog.open() // open the dialog +modals.value.dialog.close() // close the dialog +modals.value.dialog.toggle(state?) // toggle or set state + +modals.value.dialog.set({ title: 'Delete?', description: '...' }) // set attributes +modals.value.dialog.setConfirmCallback(fn) // set callback for Confirm button +modals.value.dialog.setRejectCallback(fn) // set callback for Cancel button +modals.value.dialog.reset() // restore previous attribute values +``` + +The `show` modal additionally has: + +```js +modals.value.show.loadData(data) // populate the detail view +modals.value.show.resetData() // clear detail view data +``` + +## Notes + +- The `dialog` modal is used for delete confirmations and action confirmations. Its `confirmCallback` / `rejectCallback` are set before opening. +- The `show` modal displays arbitrary item data in a read-only panel. +- The `custom` modal renders a slot-driven content area driven by `store.state.datatable.customModal`. + +## See Also + +- [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) — triggers modal opens via `actionEvents` +- [useTableNames](/system-reference/frontend/composables/table/use-table-names) — provides `deleteQuestion` / `actionDialogQuestion` text diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-names.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-names.md new file mode 100644 index 000000000..0d3ed9c9c --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-names.md @@ -0,0 +1,81 @@ +--- +sidebarTitle: useTableNames +--- + +# useTableNames + +Provides all translated title strings for a data table — the table header, form titles, delete dialog text, and subtitle — derived from the module name and the current i18n locale. + +**File:** `vue/src/js/hooks/table/useTableNames.js` + +--- + +## Props Factory + +```js +import { makeTableNamesProps } from '@/hooks/table/useTableNames' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `name` | `String` | — | Module name (snake_case), drives i18n key resolution | +| `moduleName` | `String` | — | Override the permission module name | +| `routeName` | `String` | — | Override the route name used for i18n | +| `customTitle` | `String` | — | Override the table header title (raw string, not translated) | +| `titlePrefix` | `String` | `''` | Prefix prepended to the table title | +| `titleKey` | `String` | `'name'` | Item property used as the item name in dialog text | +| `subtitle` | `String` | `''` | Table subtitle (translation key) | +| `formTitle` | `String` | — | Override both create and edit form titles | +| `formCreateTitleTranslationKey` | `String` | `'fields.new-item'` | i18n key for the create form title | +| `formEditTitleTranslationKey` | `String` | `'fields.edit-item'` | i18n key for the edit form title | +| `createFormTitle` | `String` | — | Override the create form title translation key | +| `editFormTitle` | `String` | — | Override the edit form title translation key | +| `formSubtitle` | `String` | — | Form subtitle (translation key) | +| `formCreateSubtitle` | `String` | — | Override for create-mode form subtitle | +| `formEditSubtitle` | `String` | — | Override for edit-mode form subtitle | + +## Usage + +```js +import useTableNames, { makeTableNamesProps } from '@/hooks/table/useTableNames' + +const { + tableTitle, + tableSubtitle, + formTitle, + formSubtitle, + transNameSingular, + transNamePlural, + deleteQuestion, + deleteDialogTitle, + deleteDialogDescription, +} = useTableNames(props, context) +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `snakeName` | `ComputedRef<String>` | Module name in snake_case | +| `permissionName` | `ComputedRef<String>` | Permission module name used in `can()` calls | +| `transNameSingular` | `ComputedRef<String>` | Translated singular module name (e.g. `'User'`) | +| `transNamePlural` | `ComputedRef<String>` | Translated plural module name (e.g. `'Users'`) | +| `transNameCountable` | `ComputedRef<String>` | Pluralized name with count (e.g. `'2 Users'`) | +| `tableTitle` | `ComputedRef<String>` | Table header title | +| `tableSubtitle` | `ComputedRef<String>` | Table subtitle | +| `formTitle` | `ComputedRef<String>` | Create or edit form title (switches based on `editedIndex`) | +| `formSubtitle` | `ComputedRef<String>` | Create or edit form subtitle | +| `deleteQuestion` | `ComputedRef<String>` | Confirmation question for delete dialog | +| `deleteDialogTitle` | `ComputedRef<String>` | Title for the delete confirmation dialog | +| `deleteDialogDescription` | `ComputedRef<String>` | Description text for the delete confirmation dialog | + +## Notes + +- `deleteQuestion`, `deleteDialogTitle`, and `deleteDialogDescription` use the item's `titleKey` property (default `'name'`) as the item label in the dialog text. +- For localized string values (e.g. `{ en: 'Product', tr: 'Ürün' }`), the current user locale is used to extract the display string. +- If a soft-deletable item (`deleted_at` set) is being deleted, the dialog uses `confirm-soft-deletion` keys instead of `confirm-deletion`. + +## See Also + +- [useModule](/system-reference/frontend/composables/use-module) — underlying i18n name resolution +- [useTableItem](/system-reference/frontend/composables/table/use-table-item) — provides `isSoftDeletableItem` used in dialog text selection diff --git a/docs/src/pages/system-reference/frontend/composables/table/use-table-state.md b/docs/src/pages/system-reference/frontend/composables/table/use-table-state.md new file mode 100644 index 000000000..10c6e6a58 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/table/use-table-state.md @@ -0,0 +1,65 @@ +--- +sidebarTitle: useTableState +--- + +# useTableState + +Persists and restores table UI state (page, sort, filter) across page refreshes using `localStorage` and URL query parameters. + +**File:** `vue/src/js/hooks/table/useTableState.js` + +--- + +## Usage + +```js +import { useTableState } from '@/hooks/table' + +const { + lastParameters, + queryParameters, + getLastParameters, + getQueryParameters, + setLastParameters, +} = useTableState(props, context) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `lastParameters` | `Object` | Merged state from `localStorage` + current URL query params, resolved at mount time | +| `queryParameters` | `Object` | Current URL query parameters only (parsed, JSON values decoded) | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `getQueryParameters` | `() => Object` | Parse and return current URL query parameters | +| `getLastParameters` | `() => Object` | Read `localStorage` and merge with URL query params | +| `setLastParameters` | `(parameters: Object) => void` | Persist the current table state to `localStorage` and clean up URL params | + +## localStorage Key + +State is stored per-route under: + +``` +table_filters_{window.location.pathname} +``` + +## Persisted Keys + +Only `page`, `itemsPerPage`, `sortBy`, and `filter` are persisted. `groupBy` is excluded — it is client-side only and not restored on reload. + +## Notes + +- URL query parameters take precedence over `localStorage` when both are present. +- `setLastParameters` calls `removeQueryKeys` to clean `id`, `page`, `itemsPerPage`, `sortBy`, `groupBy`, `filter`, and `replaceUrl` from the URL after persisting to `localStorage`. +- `useTableFilters` reads `lastParameters.search` and `lastParameters.filter.status` at initialization to restore the search and active filter tab. + +## See Also + +- [useTableFilters](/system-reference/frontend/composables/table/use-table-filters) — consumes `lastParameters` to restore filter/search state +- [useTable](/system-reference/frontend/composables/use-table) — calls `setLastParameters` after each `loadItems` diff --git a/docs/src/pages/system-reference/frontend/composables/use-active-table-item.md b/docs/src/pages/system-reference/frontend/composables/use-active-table-item.md new file mode 100644 index 000000000..6290f80f0 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-active-table-item.md @@ -0,0 +1,82 @@ +--- +sidebarTitle: useActiveTableItem +--- + +# useActiveTableItem + +Manages the selected row in a table and the associated detail/side panel. When a row is selected (`modelValue`), a detail panel opens; clicking outside or calling `closeItemDetails` clears the selection. + +**File:** `vue/src/js/hooks/useActiveTableItem.js` +**Props factory:** `makeActiveTableItemProps` + +--- + +## Usage + +```js +import { useActiveTableItem, makeActiveTableItemProps } from '@/hooks' + +const props = defineProps({ ...makeActiveTableItemProps() }) +const { + item, + modalStatus, + activeKey, + activeBlock, + items, + selectNested, + clickOutside, + closeItemDetails +} = useActiveTableItem(props, context) +``` + +```html +<!-- In a data-table --> +<v-data-table + v-model="item" + :items="tableItems" + @click:outside="clickOutside" +/> + +<!-- Detail panel --> +<v-navigation-drawer v-model="modalStatus"> + <div v-if="activeBlock">{{ activeBlock }}</div> +</v-navigation-drawer> +``` + +## Props (via `makeActiveTableItemProps`) + +Extends `makeModelValueProps` plus: + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `String\|Number\|Object\|Boolean` | — | The currently selected row (two-way via `v-model`) | +| `tableHeaders` | `Array` | `[]` | Table header definitions | +| `itemData` | `Object` | `{}` | A map of data blocks keyed by a string; accessed via `activeKey` | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `item` | `ComputedRef` | Two-way proxy for `modelValue` — set to `null` to deselect | +| `modalStatus` | `ComputedRef<Boolean>` | True when a row is selected and the detail panel is open | +| `modalOpened` | `Ref<Boolean>` | Whether the modal has been opened at least once | +| `modalActive` | `Ref<Boolean>` | Whether the modal content area is active | +| `activeKey` | `Ref<String\|null>` | The currently selected nested data block key | +| `activeBlock` | `ComputedRef<any>` | `itemData[activeKey]` — the resolved nested data | +| `items` | `ComputedRef<Array>` | `[item.value]` when a row is selected, `[]` otherwise | +| `selectNested` | `(key) => void` | Set `activeKey` and emit `toggle(true)` | +| `clickOutside` | `(event) => void` | Clear `item` and `activeKey` | +| `closeItemDetails` | `(key?) => void` | Close the nested block view; emit `toggle(false)` | + +## Reactive behaviour + +| Trigger | Effect | +|---------|--------| +| `item` changes to a non-null value | `modalActive` becomes `true` | +| `item` becomes `null` | `modalActive` becomes `false` | +| `activeKey` is set | `modalActive` becomes `false` (switches to nested view) | + +## See Also + +- [useModelValue](/system-reference/frontend/composables/use-model-value) — `makeModelValueProps` is used here +- [Data Tables](/guide/components/data-tables) diff --git a/docs/src/pages/system-reference/frontend/composables/use-alert.md b/docs/src/pages/system-reference/frontend/composables/use-alert.md new file mode 100644 index 000000000..34cf323a3 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-alert.md @@ -0,0 +1,45 @@ +--- +sidebarTitle: useAlert +--- + +# useAlert + +Triggers a global alert notification by committing to the Vuex `alert` store module. + +**File:** `vue/src/js/hooks/useAlert.js` + +--- + +## Usage + +```js +import { useAlert } from '@/hooks' + +const { openAlert } = useAlert() + +openAlert({ message: 'Saved successfully', variant: 'success' }) +openAlert({ message: 'Something went wrong', variant: 'error' }) +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `openAlert` | `(payload) => void` | Commit an alert to the store | + +## Payload + +| Key | Type | Description | +|-----|------|-------------| +| `message` | `String` | Alert message text | +| `variant` | `String` | `'success'` \| `'error'` \| `'warning'` \| `'info'` | +| `location` | `String` | Optional. Vuetify snackbar location, e.g. `'top'` | + +## Notes + +- The alert is rendered by the global snackbar component that reads from `store.state.alert`. +- `useItemActions` and table hooks call `openAlert` internally after server actions complete. + +## See Also + +- [useItemActions](/system-reference/frontend/composables/use-item-actions) — calls `openAlert` on action success/error diff --git a/docs/src/pages/system-reference/frontend/composables/use-authorization.md b/docs/src/pages/system-reference/frontend/composables/use-authorization.md new file mode 100644 index 000000000..379b3d55a --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-authorization.md @@ -0,0 +1,57 @@ +--- +sidebarTitle: useAuthorization +--- + +# useAuthorization + +Provides reactive permission and role checks against the authenticated user stored in Vuex. + +**File:** `vue/src/js/hooks/useAuthorization.js` + +--- + +## Usage + +```js +import { useAuthorization } from '@/hooks' + +const { can, hasRoles, isYou } = useAuthorization() + +// Check a global permission +if (can('view-reports')) { ... } + +// Check a module-scoped permission (resolves to "{module}_{permission}") +if (can('edit', 'Blog')) { ... } + +// Check whether the authenticated user matches an ID +if (isYou(item.user_id)) { ... } + +// Check role membership +if (hasRoles('admin,editor')) { ... } +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `can` | `(permission, moduleName?) => Boolean` | Returns `true` if the user is super-admin OR has the named permission. When `moduleName` is provided the permission key is `{moduleName}_{permission}`. | +| `hasRoles` | `(roles: String\|Array) => Boolean` | Returns `true` if the user has at least one of the given roles. Accepts a comma-separated string or an array. | +| `isYou` | `(id) => Boolean` | Returns `true` if `id` matches `store.getters.userProfile.id`. | + +## How permissions are resolved + +``` +can('edit', 'Blog') + → looks up store.getters.userPermissions['Blog_edit'] + → OR store.getters.isSuperAdmin (super-admin bypasses all checks) +``` + +## Notes + +- Permission names are case-sensitive and must match exactly how they are defined on the backend. +- Super-admins (`isSuperAdmin` getter) bypass all `can()` checks. + +## See Also + +- [useAuthorization in action items](/system-reference/frontend/composables/use-item-actions) — `validateAction` uses user conditions +- Backend authorization middleware: `AuthorizationMiddleware` diff --git a/docs/src/pages/system-reference/frontend/composables/use-cache.md b/docs/src/pages/system-reference/frontend/composables/use-cache.md new file mode 100644 index 000000000..d6a1bbe92 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-cache.md @@ -0,0 +1,55 @@ +--- +sidebarTitle: useCache +--- + +# useCache + +A client-side key-value cache backed by the Vuex `cache` store module. Useful for memoising computed results or storing temporary UI state between component mounts. + +**File:** `vue/src/js/hooks/useCache.js` + +--- + +## Usage + +```js +import { useCache } from '@/hooks' + +const { get, put, push, last, forget, states } = useCache() + +// Store a value +put('selectedCurrency', 'USD') + +// Retrieve it (with a fallback) +const currency = get('selectedCurrency', 'TRY') + +// Append to an array stored at a key +push('recentIds', 42) + +// Get the last appended value +const lastId = last('recentIds') + +// Delete a key +forget('selectedCurrency') +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `get` | `(key, defaultValue?) => any` | Returns the cached value or `defaultValue` | +| `put` | `(key, value) => void` | Sets or replaces a cache entry | +| `push` | `(key, value) => void` | Appends `value` to an array stored at `key` | +| `last` | `(key, defaultValue?) => any` | Returns the last element pushed to `key` | +| `forget` | `(key) => void` | Removes a cache entry | +| `states` | `ComputedRef<Object>` | Reactive reference to the entire `store.state.cache` | + +## Notes + +- The cache is **in-memory only** — it resets on page reload. +- The cache is **global** (stored in Vuex), so any component can read values set by another. +- For persistent storage across navigations, use `localStorage` or server-side preferences. + +## See Also + +- [useNavigationLayout](/system-reference/frontend/composables/use-navigation-layout) — uses `persistUiPreferences` for durable UI state diff --git a/docs/src/pages/system-reference/frontend/composables/use-cast-attributes.md b/docs/src/pages/system-reference/frontend/composables/use-cast-attributes.md new file mode 100644 index 000000000..6854deac8 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-cast-attributes.md @@ -0,0 +1,72 @@ +--- +sidebarTitle: useCastAttributes +--- + +# useCastAttributes + +Resolves dynamic attribute patterns in action config strings. Supports three syntaxes for binding item values into labels, URLs, or expressions without writing custom Vue logic. + +**File:** `vue/src/js/hooks/useCastAttributes.js` + +--- + +## Syntax Reference + +| Pattern | Example | Resolves to | +|---------|---------|-------------| +| Dot-notation `$key.sub` | `$user.name` | `item.user.name` | +| Wildcard `$items.*.title` | `$items.*.title` | Joined values of the `title` key from all items in the array | +| Eval `$(expr)$` | `$($price * 1.18)$` | Result of evaluated JS expression (item values substituted first) | + +--- + +## Usage + +```js +import { useCastAttributes } from '@/hooks' + +const { castAttribute, castObjectAttributes } = useCastAttributes() + +const item = { id: 5, user: { name: 'Alice' }, price: 100 } + +// Single attribute +castAttribute('$user.name', item) // => 'Alice' +castAttribute('$( $price * 1.18 )$', item) // => 118 + +// Deep object (all string values in the object are cast) +castObjectAttributes({ label: 'Hello $user.name', value: '$id' }, item) +// => { label: 'Hello Alice', value: '5' } +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `matchAttribute` | `(value) => Boolean` | True if value contains a dot-notation pattern | +| `matchStandardAttribute` | `(value) => Boolean` | True if value matches `$key` | +| `matchEvalAttribute` | `(value) => Boolean` | True if value matches `$(...)$` | +| `matchAnyPattern` | `(value) => Boolean` | True if any pattern matches | +| `castAttribute` | `(value, item) => any` | Cast a single value against an item | +| `castStandardAttribute` | `(value, item, options?) => any` | Cast a `$key` pattern; supports `clearAsterisk` option | +| `castEvalAttribute` | `(value, item) => any` | Evaluate a `$(expr)$` expression | +| `castObjectAttribute` | `(value, item, options?) => any` | Cast a single string value | +| `castObjectAttributes` | `(data, item) => any` | Recursively cast all string values in an object or array | + +## Where it is used + +`useItemActions` calls `castObjectAttributes(action, editingItem)` before rendering each action button, allowing action labels and endpoint URLs to reference the current row item: + +```php +// In module config +'actions' => [ + [ + 'type' => 'blank', + 'label' => 'View $name', + 'endpoint' => '/preview/:id', + ] +] +``` + +## See Also + +- [useItemActions](/system-reference/frontend/composables/use-item-actions) — main consumer of `castObjectAttributes` diff --git a/docs/src/pages/system-reference/frontend/composables/use-config.md b/docs/src/pages/system-reference/frontend/composables/use-config.md new file mode 100644 index 000000000..0b855bea7 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-config.md @@ -0,0 +1,62 @@ +--- +sidebarTitle: useConfig +--- + +# useConfig + +Provides reactive access to the application's runtime configuration stored in Vuex — environment, app name, Inertia mode, and in-flight request tracking. + +**File:** `vue/src/js/hooks/useConfig.js` + +--- + +## Usage + +```js +import { useConfig } from '@/hooks' + +const { + isHot, + appName, + appEnv, + shouldUseInertia, + isRequestInProgress, + setRequestInProgress, + increaseAxiosRequest, + decreaseAxiosRequest +} = useConfig() +``` + +```html +<v-progress-linear v-if="isRequestInProgress" indeterminate /> +<span>{{ appName }} — {{ appEnv }}</span> +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `isHot` | `ComputedRef<Boolean>` | `true` when the app is running in hot-reload (dev) mode | +| `appName` | `ComputedRef<String>` | Application name from `store.state.config.app_name` | +| `appEnv` | `ComputedRef<String>` | Environment string (`'local'`, `'production'`, etc.) from `store.state.config.app_env` | +| `shouldUseInertia` | `ComputedRef<Boolean>` | `true` when Inertia.js SPA navigation is enabled | +| `isRequestInProgress` | `ComputedRef<Boolean>` | `true` when at least one axios request is currently in-flight | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `setRequestInProgress` | `(value: Boolean) => void` | Directly set the in-progress flag | +| `increaseAxiosRequest` | `() => void` | Increment the in-flight request counter | +| `decreaseAxiosRequest` | `() => void` | Decrement the in-flight request counter | + +## Notes + +- `isRequestInProgress` is derived from a counter, not a boolean flag — `increaseAxiosRequest` / `decreaseAxiosRequest` allow multiple concurrent requests to be tracked correctly. +- `shouldUseInertia` gates navigation calls: when `true`, use `router.visit()` instead of `window.open()`. + +## See Also + +- [useInertiaRequests](/system-reference/frontend/composables/use-inertia-requests) — request state hooks diff --git a/docs/src/pages/system-reference/frontend/composables/use-currency-number.md b/docs/src/pages/system-reference/frontend/composables/use-currency-number.md new file mode 100644 index 000000000..0cdec1b0f --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-currency-number.md @@ -0,0 +1,43 @@ +--- +sidebarTitle: useCurrencyNumber +--- + +# useCurrencyNumber + +Wraps `vue-currency-input` to provide a reactive, locale-aware number input with live formatting. + +**File:** `vue/src/js/hooks/useCurrencyNumber.js` + +--- + +## Usage + +```js +import { useCurrencyNumber } from '@/hooks' + +const { inputRef, formattedValue, numberValue } = useCurrencyNumber(props) +``` + +```html +<input :ref="inputRef" /> +<span>Formatted: {{ formattedValue }}</span> +<span>Raw number: {{ numberValue }}</span> +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `inputRef` | `Ref` | Template ref that must be bound to the `<input>` element | +| `formattedValue` | `ComputedRef<String>` | The input value rendered as a formatted string (e.g. `'1.234,56'`) | +| `numberValue` | `ComputedRef<Number\|null>` | The underlying numeric value, or `null` when empty | + +## Notes + +- Uses EUR locale conventions (`de-DE`) for grouping and decimal separators by default; the locale is configurable via props passed to `vue-currency-input`. +- Emits are handled internally by `vue-currency-input`; bind `v-model` through `numberValue` / a watcher rather than listening to native `input` events. + +## See Also + +- [useCurrency](/system-reference/frontend/composables/use-currency) — simple display-only price formatter +- [useLocale](/system-reference/frontend/composables/use-locale) — active locale helpers diff --git a/docs/src/pages/system-reference/frontend/composables/use-currency.md b/docs/src/pages/system-reference/frontend/composables/use-currency.md new file mode 100644 index 000000000..b913f2ae4 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-currency.md @@ -0,0 +1,39 @@ +--- +sidebarTitle: useCurrency +--- + +# useCurrency + +Provides a `formatPrice` helper that formats a numeric amount as a localized currency string. + +**File:** `vue/src/js/hooks/useCurrency.js` + +--- + +## Usage + +```js +import { useCurrency } from '@/hooks' + +const { formatPrice } = useCurrency() + +const display = formatPrice(1999.5, '€') +// → '€ 1.999,50' (locale-dependent) +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `formatPrice` | `(amount: Number, symbol?: String) => String` | Format `amount` as a currency string. Uses the active i18n locale to apply thousand-separators and decimal notation. `symbol` is prepended to the result. | + +## Notes + +- Delegates formatting to the `formatCurrencyPrice` utility from `@/utils/`. +- The locale used is the current vue-i18n active locale, so the decimal separator and grouping separator change automatically with the user's language. +- When `symbol` is omitted the amount is formatted without a currency prefix. + +## See Also + +- [useCurrencyNumber](/system-reference/frontend/composables/use-currency-number) — wraps `vue-currency-input` for interactive number inputs +- [useLocale](/system-reference/frontend/composables/use-locale) — active locale helpers diff --git a/docs/src/pages/system-reference/frontend/composables/use-draggable.md b/docs/src/pages/system-reference/frontend/composables/use-draggable.md new file mode 100644 index 000000000..f7daa122a --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-draggable.md @@ -0,0 +1,51 @@ +--- +sidebarTitle: useDraggable +--- + +# useDraggable + +Provides drag-and-drop props and reactive `dragOptions` for components that use Sortable.js (via `vue-draggable-next`). + +**File:** `vue/src/js/hooks/useDraggable.js` +**Props factory:** `makeDraggableProps` + +--- + +## Usage + +```js +import { useDraggable, makeDraggableProps } from '@/hooks' + +// In your component +const props = defineProps({ ...makeDraggableProps() }) +const { dragOptions } = useDraggable(props, context) +``` + +```html +<draggable v-bind="dragOptions" v-model="items"> + <template #item="{ element }">...</template> +</draggable> +``` + +## Props (via `makeDraggableProps`) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `draggable` | `Boolean` | `false` | Enables drag-and-drop when `true` | +| `orderKey` | `String` | `'position'` | The model key that stores the sort order | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `dragOptions` | `ComputedRef<Object>` | Options object to pass to `<draggable>`. Contains `disabled` (flipped from `props.draggable`) and `animation: 150`. | + +## Notes + +- When `draggable` is `false`, `dragOptions.disabled` is `true` and the list is static. +- `useRepeater` and `useFile`/`useImage` spread `makeDraggableProps` into their own prop definitions, inheriting drag support automatically. + +## See Also + +- [useRepeater](/system-reference/frontend/composables/use-repeater) — uses `draggable` + `orderKey` to reorder blocks +- [useFile](/system-reference/frontend/composables/use-file) — file list supports draggable reorder diff --git a/docs/src/pages/system-reference/frontend/composables/use-dynamic-modal.md b/docs/src/pages/system-reference/frontend/composables/use-dynamic-modal.md new file mode 100644 index 000000000..e8b752210 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-dynamic-modal.md @@ -0,0 +1,75 @@ +--- +sidebarTitle: useDynamicModal +--- + +# useDynamicModal + +Returns the globally registered `ModalService` instance via Vue's `inject`. Use it to open a confirmation or custom modal from anywhere in the component tree without managing modal state locally. + +**File:** `vue/src/js/hooks/useDynamicModal.js` + +--- + +## Prerequisites + +The `ModalService` plugin must be installed at the app root: + +```js +import { ModalService } from '@/plugins/modalService' +app.use(ModalService) +``` + +Calling `useDynamicModal` without the plugin installed throws: + +``` +[ModalService] not installed. Did you forget `app.use(ModalService)`? +``` + +--- + +## Usage + +```js +import { useDynamicModal } from '@/hooks' + +const modal = useDynamicModal() + +// Open a confirmation dialog +modal.open(null, { + modalProps: { + title: 'Are you sure?', + confirmCallback: async () => { + await deleteItem() + return true // close the modal + } + } +}) +``` + +## Returns + +The hook returns the `ModalService` instance directly. Refer to the `ModalService` plugin API for all available methods (`open`, `close`, etc.). + +## How it is used internally + +`useItemActions` calls `useDynamicModal().open(...)` when an action has `hasConfirmation: true`: + +```php +// Module config +'actions' => [ + [ + 'type' => 'request', + 'label' => 'Archive', + 'endpoint' => '/items/:id/archive', + 'hasConfirmation' => true, + 'confirmationModalAttributes' => [ + 'title' => 'Confirm Archive', + ], + ] +] +``` + +## See Also + +- [useModal](/system-reference/frontend/composables/use-modal) — local modal open/close state +- [useItemActions](/system-reference/frontend/composables/use-item-actions) — uses `useDynamicModal` for confirmation dialogs diff --git a/docs/src/pages/system-reference/frontend/composables/use-file.md b/docs/src/pages/system-reference/frontend/composables/use-file.md new file mode 100644 index 000000000..47ddbfaa5 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-file.md @@ -0,0 +1,74 @@ +--- +sidebarTitle: useFile +--- + +# useFile + +Manages the state for a file input that is backed by the media library. Handles selection sync from the Vuex `mediaLibrary` store, drag-and-drop reorder, and item deletion. + +**File:** `vue/src/js/hooks/useFile.js` +**Props factory:** `makeFileProps` + +--- + +## Usage + +```js +import { useFile, makeFileProps } from '@/hooks' + +const props = defineProps({ ...makeFileProps() }) +const { + input, + items, + remainingItems, + isDraggable, + mediableActive, + addLabel, + deleteItem, + deleteAll +} = useFile(props, context) +``` + +## Props (via `makeFileProps`) + +Extends `makeInputProps` and `makeDraggableProps` plus: + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `mediaType` | `String` | `'file'` | Media type sent to the media library | +| `name` | `String` | required | Field name — used as the media library slot key | +| `itemLabel` | `String` | `t('File')` | Label used in the add button | +| `endpoint` | `String` | `''` | Upload endpoint | +| `max` | `Number` | `1` | Maximum number of files | +| `note` | `String` | `''` | Note text displayed in the input | +| `fieldNote` | `String` | `''` | Field-level note | +| `filesizeMax` | `Number` | `0` | Maximum file size in bytes (0 = unlimited) | +| `buttonOnTop` | `Boolean` | `false` | Display the add button above the file list | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `input` | `Ref<Array>` | The current file list (synced with `modelValue`) | +| `items` | `ComputedRef<Array>` | Files selected via the media library store | +| `remainingItems` | `ComputedRef<Number>` | `max - input.length` | +| `isDraggable` | `ComputedRef<Boolean>` | True when `draggable` and more than one file | +| `mediableActive` | `Ref<Boolean>` | Controls whether media library selections are applied | +| `addLabel` | `ComputedRef<String>` | Localised add button label | +| `deleteItem` | `(index) => void` | Remove file at `index` | +| `deleteAll` | `() => void` | Clear all files | +| _(all useInput returns)_ | | `id`, `updateModelValue`, `isEditing`, etc. | + +## Sync behaviour + +The hook watches three sources and keeps them in sync: + +1. `store.state.mediaLibrary.selected[props.name]` — when the media library inserts files +2. `states.input` — emits `update:modelValue` on change +3. `props.modelValue` — external model updates are reflected in `states.input` + +## See Also + +- [useImage](/system-reference/frontend/composables/use-image) — identical API for image inputs +- [useDraggable](/system-reference/frontend/composables/use-draggable) — drag-and-drop props +- [File storage with FilePond](/guide/generics/file-storage-with-filepond) diff --git a/docs/src/pages/system-reference/frontend/composables/use-filepond.md b/docs/src/pages/system-reference/frontend/composables/use-filepond.md new file mode 100644 index 000000000..718b427eb --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-filepond.md @@ -0,0 +1,75 @@ +--- +sidebarTitle: useFilepond +--- + +# useFilepond + +Sets up FilePond upload props and derives validation rules from the component's schema and prop values. Used internally by `VInputFilepond` and `VInputFilepondAvatar`. + +**File:** `vue/src/js/hooks/useFilepond.js` +**Props factory:** `makeFilepondProps` + +--- + +## Usage + +```js +import { useFilepond, makeFilepondProps } from '@/hooks' + +const props = defineProps({ ...makeFilepondProps() }) +const { filepondRules, max } = useFilepond(props, context) +``` + +```html +<file-pond + :max-files="max" + :rules="filepondRules" + ... +/> +``` + +## Props (via `makeFilepondProps`) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `maxFiles` | `Number` | `2` | Maximum number of files | +| `min` | `Number` | — | Minimum required files | +| `rules` | `Array` | `[]` | Additional validation rules | +| `noRules` | `Boolean` | `false` | Skip rule injection | +| `endPoints` | `Object` | `{}` | FilePond server endpoint config | +| `acceptedFileTypes` | `String` | `''` | Comma-separated MIME types | +| `allowImagePreview` | `Boolean` | `false` | Enable image preview plugin | +| `allowMultiple` | `Boolean` | `false` | Allow multiple file uploads | +| `allowProcess` | `Boolean` | `true` | Auto-upload on file add | +| `allowRemove` | `Boolean` | `true` | Allow file removal | +| `allowDrop` | `Boolean` | `true` | Allow drag-to-drop upload | +| `allowReorder` | `Boolean` | `false` | Allow drag to reorder files | +| `allowReplace` | `Boolean` | `false` | Replace file on re-upload | +| `allowFileSizeValidation` | `Boolean` | `true` | Enable file size validation | +| `maxFileSize` | `String` | `'5MB'` | Maximum single file size | +| `minFileSize` | `String` | `'1KB'` | Minimum single file size | +| `maxTotalFileSize` | `String` | `null` | Maximum total upload size | +| `hint` | `String` | `null` | Helper text | +| `hideDetails` | `Boolean` | `false` | Hide error/hint details | +| `disabled` | `Boolean` | `false` | Disable the input | +| `labelWeight` | `String` | `'regular'` | Label font weight | +| `subtitle` | `String` | `null` | Subtitle text | +| `subtitleWeight` | `String` | `'thin'` | Subtitle font weight | +| `hintWeight` | `String` | `'thin'` | Hint font weight | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `filepondRules` | `Ref<Array>` | Computed validation rule array (includes a required rule if `min` is set and the input is creatable/editable) | +| `max` | `Ref<Number>` | Effective max files (clamped to be ≥ `min`, minimum `5` if zero) | + +## Rule injection logic + +When `min > 0` and `noRules` is `false` and the input is in create/edit mode, a `required:array:{min}` rule is automatically prepended to `filepondRules`. + +## See Also + +- [File storage with FilePond](/guide/generics/file-storage-with-filepond) +- [useFile](/system-reference/frontend/composables/use-file) — media-library file input +- [input-filepond-avatar](/guide/form-inputs/input-filepond-avatar) — avatar upload component diff --git a/docs/src/pages/system-reference/frontend/composables/use-form-base-logic.md b/docs/src/pages/system-reference/frontend/composables/use-form-base-logic.md new file mode 100644 index 000000000..838b644aa --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-form-base-logic.md @@ -0,0 +1,72 @@ +--- +sidebarTitle: useFormBaseLogic +--- + +# useFormBaseLogic + +Core logic for the `FormBase` component — flattens nested schema sections, binds schema fields to the model, and manages drag-and-drop reordering, cascade selects, and grid layout attributes. + +**File:** `vue/src/js/hooks/useFormBaseLogic.js` + +--- + +## Usage + +```js +import { useFormBaseLogic } from '@/hooks' + +const { + flatCombinedArray, + flatCombinedArraySorted, + bindSchema, + onInput, + mapTypeToComponent, + getGridAttributes, +} = useFormBaseLogic(props, context) +``` + +## Returns + +### Computed + +| Name | Type | Description | +|------|------|-------------| +| `flatCombinedArray` | `ComputedRef<Array>` | Flat list of all schema fields across all sections, in declaration order | +| `flatCombinedArraySorted` | `ComputedRef<Array>` | Same list sorted by `sidebarPos` / `order`, respecting drag-and-drop overrides | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `bindSchema` | `(field, model) => Object` | Merges a schema field definition with the current model value, returning a fully bound field object ready for an input component | +| `onInput` | `(key, value) => void` | Propagates a field value change upward to the parent form and triggers cascade-select resolution | +| `mapTypeToComponent` | `(type: String) => String` | Resolves a schema field `type` string (e.g. `'text'`, `'select'`, `'repeater'`) to its corresponding Vue component name | +| `getGridAttributes` | `(field) => Object` | Returns Vuetify grid props (`cols`, `sm`, `md`, `lg`) for a schema field based on its `grid` config | + +### Drag-and-Drop + +| Name | Type | Description | +|------|------|-------------| +| `dragstart` | `Function` | `dragstart` event handler — marks the dragged field | +| `dragover` | `Function` | `dragover` event handler — allows drop | +| `drop` | `Function` | `drop` event handler — reorders `flatCombinedArraySorted` | + +## Cascade Selects + +When a field has `cascade` configured, `onInput` automatically fetches the dependent field's options from the server whenever the parent field value changes. + +## Slot Name Generators + +`useFormBaseLogic` exports helper functions used by `FormBase` to generate slot names for custom field rendering: + +```js +// e.g. for a field with key "user_id": +slotName(field) // → 'field.user_id' +headerSlotName(field) // → 'header.user_id' +``` + +## See Also + +- [useFormBase](/system-reference/frontend/composables/use-form-base) — thin alias +- [useForm](/system-reference/frontend/composables/use-form) — top-level form that owns model and submission +- [useInput](/system-reference/frontend/composables/use-input) — per-input state diff --git a/docs/src/pages/system-reference/frontend/composables/use-form-base.md b/docs/src/pages/system-reference/frontend/composables/use-form-base.md new file mode 100644 index 000000000..be52f9e2c --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-form-base.md @@ -0,0 +1,33 @@ +--- +sidebarTitle: useFormBase +--- + +# useFormBase + +Thin alias that calls `useFormBaseLogic` and re-exports its return value. Import `useFormBase` when consuming the combined field-iteration API without caring about the internal split. + +**File:** `vue/src/js/hooks/useFormBase.js` + +--- + +## Usage + +```js +import { useFormBase } from '@/hooks' + +const { flatCombinedArraySorted, bindSchema, onInput } = useFormBase(props, context) +``` + +## Returns + +All values from [useFormBaseLogic](/system-reference/frontend/composables/use-form-base-logic). See that page for the full reference. + +## Notes + +- `useFormBase` exists purely as a convenience import alias. All logic resides in `useFormBaseLogic`. +- The `FormBase` Vue component imports this hook to render its slot-driven field layout. + +## See Also + +- [useFormBaseLogic](/system-reference/frontend/composables/use-form-base-logic) — full implementation +- [useForm](/system-reference/frontend/composables/use-form) — top-level form state diff --git a/docs/src/pages/system-reference/frontend/composables/use-form.md b/docs/src/pages/system-reference/frontend/composables/use-form.md new file mode 100644 index 000000000..3eca5a5a6 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-form.md @@ -0,0 +1,101 @@ +--- +sidebarTitle: useForm +--- + +# useForm + +Top-level form composable. Manages form model state, submission, schema watching, server-side error binding, and orchestrates input `handleInput` / `handleClick` callbacks. + +**File:** `vue/src/js/hooks/useForm.js` + +--- + +## Props Factory + +```js +import { makeFormProps } from '@/hooks/useForm' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Object` | `{}` | The form's data model (v-model) | +| `schema` | `Object` | `{}` | Full schema definition — drives field list, rules, and layout | +| `endpoint` | `String` | `''` | API endpoint for form submission | +| `method` | `String` | `'post'` | HTTP method (`post`, `put`, `patch`) | +| `id` | `String` | auto | HTML form `id` attribute | +| `resetOnSuccess` | `Boolean` | `false` | Reset the model to defaults after a successful submission | +| `redirectOnSuccess` | `String` | `null` | URL to redirect to after success | +| `formActions` | `Array\|Object` | `[]` | Additional action button definitions | +| `hideDefaultActions` | `Boolean` | `false` | Hide the default submit/cancel buttons | +| `submitText` | `String` | `t('Save')` | Label for the primary submit button | +| `cancelText` | `String` | `t('Cancel')` | Label for the cancel button | +| `disabled` | `Boolean` | `false` | Disable all inputs in the form | +| `readonly` | `Boolean` | `false` | Make all inputs read-only | + +## Usage + +```js +import { useForm, makeFormProps } from '@/hooks/useForm' + +const props = defineProps(makeFormProps()) +const emit = defineEmits(['update:modelValue', 'success', 'error']) + +const { + model, + saveForm, + submit, + handleInput, + handleClick, + setSchemaErrors, + resetSchemaErrors, + createSchema, + validModel, + formLoading +} = useForm(props, emit) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `model` | `Ref<Object>` | Reactive copy of the form's data model | +| `formLoading` | `ComputedRef<Boolean>` | `true` while a submission is in progress | +| `validModel` | `Ref<Boolean\|null>` | Vuetify form validity state (`null` = not yet validated) | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `saveForm` | `() => Promise` | Validate, then submit via `formApi.post/put/patch` | +| `submit` | `() => void` | Trigger form validation and call `saveForm` | +| `handleInput` | `(key, value) => void` | Called by child inputs to update `model[key]` | +| `handleClick` | `(action) => void` | Dispatch a form-level action button click | +| `setSchemaErrors` | `(errors: Object) => void` | Map server validation errors back onto schema fields | +| `resetSchemaErrors` | `() => void` | Clear all server-side error messages from the schema | +| `createSchema` | `(definition) => Object` | Normalize and hydrate a raw schema definition into a resolved schema | + +## Watchers + +- Watches `props.modelValue` — syncs external model changes into the internal `model`. +- Watches `props.schema` — re-runs `createSchema` when the schema definition changes. + +## Server-side Errors + +After a failed submission, call `setSchemaErrors(errors)` with the Laravel validation error bag: + +```js +setSchemaErrors({ + name: ['The name field is required.'], + email: ['The email has already been taken.'] +}) +``` + +Each field matching a key in `errors` will display the first error message beneath the input. + +## See Also + +- [useInput](/system-reference/frontend/composables/use-input) — per-input state and `updateModelValue` +- [useFormBase](/system-reference/frontend/composables/use-form-base) — field iteration and layout rendering +- [useValidation](/system-reference/frontend/composables/use-validation) — rule factories diff --git a/docs/src/pages/system-reference/frontend/composables/use-formatter.md b/docs/src/pages/system-reference/frontend/composables/use-formatter.md new file mode 100644 index 000000000..4de093f3d --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-formatter.md @@ -0,0 +1,89 @@ +--- +sidebarTitle: useFormatter +--- + +# useFormatter + +Provides named column value formatters for data tables. Each formatter transforms a raw cell value into a render descriptor (tag, attributes, text) that the table cell component renders. + +**File:** `vue/src/js/hooks/useFormatter.js` +**Props factory:** `makeFormatterProps` + +--- + +## Usage + +```js +import { useFormatter, makeFormatterProps } from '@/hooks' + +const props = defineProps({ ...makeFormatterProps() }) +const { formatterColumns, handleFormatter } = useFormatter(props, context, headers) +``` + +```html +<!-- In a table cell slot --> +<template #item.status="{ value }"> + <u-formatter :config="handleFormatter(['status'], value)" /> +</template> +``` + +## Props (via `makeFormatterProps`) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `ignoreFormatters` | `Array` | `[]` | List of formatter names to skip for specific columns | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `formatterColumns` | `ComputedRef<Array>` | Headers that have a `formatter` or `formatterName` property | +| `handleFormatter` | `(formatter, value) => Object` | Resolves a formatter by name and returns a render config | +| `dateFormatter` | `Function` | Formats ISO date strings using `vue-i18n` `d()` | +| `chipFormatter` | `Function` | Wraps value in a `v-chip` config | +| `badgeFormatter` | `Function` | Wraps value in a `v-badge` config | +| `statusFormatter` | `Function` | Renders a green ✓ / red ✗ icon based on truthiness | +| `shortenFormatter` | `Function` | Truncates a string to `max` characters | +| `priceFormatter` | `Function` | Renders price with currency unit and optional tax | +| `pascalFormatter` | `Function` | Converts a string to PascalCase | +| `editFormatter` | `Function` | Wraps value in a clickable `<span>` | + +## Formatter config in table headers + +Define `formatter` on a header object in your module config: + +```php +[ + 'key' => 'status', + 'title' => 'Status', + 'formatter' => ['status'], // name only +], +[ + 'key' => 'created_at', + 'title' => 'Created', + 'formatter' => ['date', 'short'], // name + additional args +], +[ + 'key' => 'price', + 'title' => 'Price', + 'formatter' => ['price', '₺'], +], +``` + +## Render descriptor shape + +`handleFormatter` returns: + +```js +{ + configuration: { + tag: 'v-chip', // optional; defaults to plain text + attributes: { ... }, // optional; passed to the tag + elements: 'cell value' // inner content + } +} +``` + +## See Also + +- [Data Tables](/guide/components/data-tables) — how formatters are declared in the table config diff --git a/docs/src/pages/system-reference/frontend/composables/use-image.md b/docs/src/pages/system-reference/frontend/composables/use-image.md new file mode 100644 index 000000000..1a92e4ee9 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-image.md @@ -0,0 +1,75 @@ +--- +sidebarTitle: useImage +--- + +# useImage + +Manages the state for an image input backed by the media library. Identical API to `useFile` but defaults `mediaType` to `'image'` and opens the media library in image mode. + +**File:** `vue/src/js/hooks/useImage.js` +**Props factory:** `makeImageProps` + +--- + +## Usage + +```js +import { useImage, makeImageProps } from '@/hooks' + +const props = defineProps({ ...makeImageProps() }) +const { + input, + items, + remainingItems, + isDraggable, + mediableActive, + addLabel, + deleteItem, + deleteAll +} = useImage(props, context) +``` + +## Props (via `makeImageProps`) + +Extends `makeInputProps` and `makeDraggableProps` plus: + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `mediaType` | `String` | `'image'` | Media type passed to the media library | +| `name` | `String` | required | Field name — media library slot key | +| `itemLabel` | `String` | `t('Image')` | Label used in the add button | +| `btnLabel` | `String` | `t('fields.medias.btn-label')` | Add image button label | +| `max` | `Number` | `1` | Maximum number of images | +| `disabled` | `Boolean` | `false` | Disable the input | +| `required` | `Boolean` | `false` | Mark as required | +| `hover` | `Boolean` | `false` | Enable hover preview | +| `isSlide` | `Boolean` | `false` | Render as a slideshow slot | +| `index` | `Number` | `0` | Index in a multi-context media slot | +| `mediaContext` | `String` | `''` | Media library context key (e.g. `'cover'`) | +| `activeCrop` | `Boolean` | `true` | Show crop tool in the media library | +| `widthMin` | `Number` | `0` | Minimum accepted image width in px | +| `heightMin` | `Number` | `0` | Minimum accepted image height in px | +| `note` | `String` | `''` | Note text | +| `fieldNote` | `String` | `''` | Field-level note | +| `filesizeMax` | `Number` | `0` | Maximum file size (0 = unlimited) | +| `buttonOnTop` | `Boolean` | `false` | Display add button above the image list | + +## Returns + +Same as [useFile](/system-reference/frontend/composables/use-file): + +| Name | Type | Description | +|------|------|-------------| +| `input` | `Ref<Array>` | The current image list | +| `items` | `ComputedRef<Array>` | Images from the media library store | +| `remainingItems` | `ComputedRef<Number>` | `max - input.length` | +| `isDraggable` | `ComputedRef<Boolean>` | True when draggable and more than one image | +| `mediableActive` | `Ref<Boolean>` | Controls media library selection application | +| `addLabel` | `ComputedRef<String>` | Localised add button label | +| `deleteItem` | `(index) => void` | Remove image at index | +| `deleteAll` | `() => void` | Clear all images | + +## See Also + +- [useFile](/system-reference/frontend/composables/use-file) — identical hook for non-image files +- [useDraggable](/system-reference/frontend/composables/use-draggable) — drag-and-drop props diff --git a/docs/src/pages/system-reference/frontend/composables/use-inertia-requests.md b/docs/src/pages/system-reference/frontend/composables/use-inertia-requests.md new file mode 100644 index 000000000..849273c14 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-inertia-requests.md @@ -0,0 +1,51 @@ +--- +sidebarTitle: useInertiaRequests +--- + +# useInertiaRequests + +Exposes reactive state for in-flight Inertia.js requests. Use it to display loading indicators or disable UI elements while a navigation or form submission is pending. + +**File:** `vue/src/js/hooks/useInertiaRequests.js` + +--- + +## Usage + +```js +import { useInertiaRequests, useInertiaLoading } from '@/hooks' + +// Full hook +const { isLoading, activeRequestCount, hasActiveRequests } = useInertiaRequests() + +// Loading-only shorthand +const { isLoading, loadingText } = useInertiaLoading() +``` + +```html +<v-progress-linear :active="isLoading" indeterminate /> +<v-btn :disabled="isLoading">Save</v-btn> +``` + +## `useInertiaRequests` Returns + +| Name | Type | Description | +|------|------|-------------| +| `activeRequestCount` | `ComputedRef<Number>` | Number of in-flight Inertia requests (from `store.state.config.axiosRequestCount`) | +| `hasActiveRequests` | `ComputedRef<Boolean>` | True when count > 0 | +| `isLoading` | `ComputedRef<Boolean>` | Alias for `hasActiveRequests` | +| `getRequestCount` | `() => Number` | Non-reactive snapshot of the current count | +| `hasRequests` | `() => Boolean` | Non-reactive snapshot of whether requests are active | + +## `useInertiaLoading` Returns + +| Name | Type | Description | +|------|------|-------------| +| `isLoading` | `ComputedRef<Boolean>` | True while any request is in flight | +| `loadingText` | `ComputedRef<String>` | `''` when idle, `'Loading...'` for one request, `'Loading... (N requests)'` for multiple | +| `activeRequestCount` | `ComputedRef<Number>` | Number of in-flight requests | + +## Notes + +- The request count is incremented/decremented by Inertia interceptors registered in `vue/src/js/setup/inertia-interceptors.js`. +- For Axios-based requests (non-Inertia), the count comes from `store.state.config.axiosRequestCount`. diff --git a/docs/src/pages/system-reference/frontend/composables/use-input-fetch.md b/docs/src/pages/system-reference/frontend/composables/use-input-fetch.md new file mode 100644 index 000000000..7ef70ea41 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-input-fetch.md @@ -0,0 +1,76 @@ +--- +sidebarTitle: useInputFetch +--- + +# useInputFetch + +Handles paginated remote data fetching for select-like inputs (autocomplete, combobox, select-scroll). Builds a URL from `endpoint` + `page` + `search` params, appends pages on scroll, and re-fetches on search change. + +**File:** `vue/src/js/hooks/useInputFetch.js` +**Props factory:** `makeInputFetchProps` + +--- + +## Usage + +```js +import { useInputFetch, makeInputFetchProps } from '@/hooks' + +const props = defineProps({ ...makeInputFetchProps() }) +const { + elements, + itemsLoading, + activePage, + activeLastPage, + nextPage, + getItemsFromApi, + searchOnInputFetch +} = useInputFetch(props, context) +``` + +```html +<v-select + :items="elements" + :loading="itemsLoading" + @update:search="searchOnInputFetch" +/> +``` + +## Props (via `makeInputFetchProps`) + +Merges `makeSelectProps` and `makePaginationProps`: + +| Prop | Type | Description | +|------|------|-------------| +| `endpoint` | `String` | Base URL to fetch items from | +| `itemValue` | `String` | Key used as the option value | +| `itemTitle` | `String` | Key used as the option label | +| `multiple` | `Boolean` | Whether multiple values can be selected | +| `page` | `Number` | Starting page (default `1`) | +| `perPage` | `Number` | Items per page | +| `searchParam` | `String` | Query param name for search (default `'search'`) | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `elements` | `Ref<Array>` | Accumulated items fetched so far | +| `itemsLoading` | `Ref<Boolean>` | True while a request is in flight | +| `activePage` | `Ref<Number>` | Current page number | +| `activeLastPage` | `Ref<Number>` | Last page from the API response (`-1` means not yet loaded) | +| `nextPage` | `Ref<Number>` | Next page to request | +| `getItemsFromApi` | `() => Promise` | Fetch the next page and append to `elements` | +| `searchOnInputFetch` | `(searchVal) => void` | Reset pagination and re-fetch with a new search term | + +## Pagination behaviour + +- Pages are fetched sequentially. `getItemsFromApi` is a no-op once `nextPage > activeLastPage`. +- On the first call, `activeLastPage` is `-1` (sentinel for "not loaded yet"), which forces an initial fetch. +- When `search` is non-empty, `elements` is replaced (not appended) so results always reflect the query. +- After fetching, if the current `modelValue` is not yet in `elements`, the hook calls itself recursively to fetch more pages until the selected value is found. + +## See Also + +- [input-select-scroll](/guide/form-inputs/input-select-scroll) +- [input-autocomplete](/guide/form-inputs/input-autocomplete) +- [input-combobox](/guide/form-inputs/input-combobox) diff --git a/docs/src/pages/system-reference/frontend/composables/use-input-handlers.md b/docs/src/pages/system-reference/frontend/composables/use-input-handlers.md new file mode 100644 index 000000000..a8f883e48 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-input-handlers.md @@ -0,0 +1,65 @@ +--- +sidebarTitle: useInputHandlers +--- + +# useInputHandlers + +Provides slot-driven click handler dispatch for form inputs. When a schema field declares `slotHandlers`, clicking an input slot (e.g. an append icon) resolves the matching handler by name and invokes it. + +**File:** `vue/src/js/hooks/useInputHandlers.js` + +--- + +## Usage + +```js +import { useInputHandlers } from '@/hooks' + +const { invokeInputClickHandler } = useInputHandlers() + +// Called from a slot click event in FormBaseField +invokeInputClickHandler(obj, 'append-inner') +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `invokeInputClickHandler` | `(obj, slotName) => void` | Looks up `obj.schema.slotHandlers[camelCase(slotName)]` and calls the matching `{name}Handler` method | +| `passwordHandler` | `(obj, slotName) => void` | Built-in handler: toggles `obj.schema.type` between `'password'` and `'text'`, and swaps the append icon | + +## Built-in handlers + +| Handler name | Triggered by schema key | Effect | +|--------------|------------------------|--------| +| `password` | `slotHandlers: { appendInner: 'password' }` | Toggles password visibility and icon | + +## Adding a handler in schema config + +```php +// In your module's input config +[ + 'type' => 'password', + 'name' => 'password', + 'label' => 'Password', + 'slotHandlers' => [ + 'appendInner' => 'password', // camelCase slot name → handler name + ], + 'appendInnerIcon' => '$visibility', +] +``` + +## How resolution works + +``` +slotName 'append-inner' + → camelCase → 'appendInner' + → look up obj.schema.slotHandlers.appendInner → 'password' + → camelCase → 'password' + → call passwordHandler(obj, 'appendInner') +``` + +## Notes + +- This hook is a low-level primitive called by `FormBaseField`. You rarely need it directly. +- Add custom handlers by extending the `methods` reactive object in your own composable that wraps `useInputHandlers`. diff --git a/docs/src/pages/system-reference/frontend/composables/use-input.md b/docs/src/pages/system-reference/frontend/composables/use-input.md new file mode 100644 index 000000000..7be6caf09 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-input.md @@ -0,0 +1,81 @@ +--- +sidebarTitle: useInput +--- + +# useInput + +Base composable for all form inputs. Provides `modelValue` binding, schema-driven prop resolution, and the `updateModelValue` / `emitModelValue` contract that every input component must follow. + +**File:** `vue/src/js/hooks/useInput.js` + +--- + +## Props Factory + +```js +import { makeInputProps } from '@/hooks/useInput' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `any` | `null` | The bound value (v-model) | +| `schema` | `Object` | `{}` | Schema field definition — label, rules, placeholder, etc. | +| `disabled` | `Boolean` | `false` | Disable the input | +| `readonly` | `Boolean` | `false` | Make the input read-only | +| `clearable` | `Boolean` | `false` | Show a clear button | +| `label` | `String` | `''` | Override the label from the schema | +| `placeholder` | `String` | `''` | Override the placeholder from the schema | +| `hint` | `String` | `''` | Hint text shown below the input | +| `density` | `String` | `'comfortable'` | Vuetify density (`compact`, `comfortable`, `default`) | + +## Emits Factory + +```js +import { makeInputEmits } from '@/hooks/useInput' +``` + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `any` | Emitted when the value changes | +| `input` | `any` | Emitted on every keystroke / change | + +## Injects Factory + +```js +import { makeInputInjects } from '@/hooks/useInput' +``` + +Provides access to form-level inject keys (parent form context, schema bindings). + +## Usage + +```js +import { useInput, makeInputProps, makeInputEmits } from '@/hooks/useInput' + +const props = defineProps(makeInputProps()) +const emit = defineEmits(makeInputEmits()) + +const { id, boundProps, input, initialValue, updateModelValue, emitModelValue } = useInput(props, emit) +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `id` | `ComputedRef<String>` | Unique input ID derived from the schema field name | +| `boundProps` | `ComputedRef<Object>` | Merged props object ready to spread onto the underlying Vuetify input component | +| `input` | `ComputedRef<Object>` | Resolved schema field with all defaults applied | +| `initialValue` | `any` | The value at mount time, used to detect changes | +| `updateModelValue` | `(value) => void` | Set the internal value and emit `update:modelValue` | +| `emitModelValue` | `(value) => void` | Emit `update:modelValue` without updating internal state (use for pass-through) | + +## Notes + +- `boundProps` automatically merges schema-defined props (label, placeholder, rules, disabled) with explicitly passed props. Explicit props take precedence. +- All hydrate-backed input components (`InputText`, `InputSelect`, etc.) call `useInput` internally. + +## See Also + +- [useValidation](/system-reference/frontend/composables/use-validation) — rule factories used by `useInput` +- [useModelValue](/system-reference/frontend/composables/use-model-value) — lower-level v-model proxy +- [useForm](/system-reference/frontend/composables/use-form) — top-level form that owns the model diff --git a/docs/src/pages/system-reference/frontend/composables/use-item-actions.md b/docs/src/pages/system-reference/frontend/composables/use-item-actions.md new file mode 100644 index 000000000..c9966fa2c --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-item-actions.md @@ -0,0 +1,105 @@ +--- +sidebarTitle: useItemActions +--- + +# useItemActions + +Processes the `actions` array from a form schema into executable, filtered, and attribute-cast action buttons. Handles four action types: `request`, `modal`, `download`, and `blank`. + +**File:** `vue/src/js/hooks/useItemActions.js` +**Props factory:** `makeItemActionsProps` + +--- + +## Usage + +```js +import { useItemActions, makeItemActionsProps } from '@/hooks' + +const props = defineProps({ ...makeItemActionsProps() }) +const { allActions, visibleActions, hasVisibleActions, handleAction } = useItemActions(props, context) +``` + +```html +<template v-if="hasVisibleActions"> + <v-btn + v-for="action in visibleActions" + :key="action.label" + @click="handleAction(action)" + > + {{ action.label }} + </v-btn> +</template> +``` + +## Props (via `makeItemActionsProps`) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `isEditing` | `Boolean` | `false` | Whether the form is in edit mode | +| `actions` | `Array\|Object` | `[]` | Action definitions from the module config | + +## Action definition shape + +```php +// In module config +'actions' => [ + [ + 'type' => 'request', // 'request' | 'modal' | 'download' | 'blank' + 'label' => 'Archive $name', + 'endpoint' => '/items/:id/archive', + 'method' => 'post', // for 'request' type + 'params' => [], // request body params + 'editable' => true, // show in edit mode + 'creatable' => false, // show in create mode + 'hasConfirmation' => true, // show confirmation modal first + 'reloadOnSuccess' => true, // reload page after success + 'reloadDelay' => 1000, // ms delay before reload + 'hideOnCondition' => false, // hide (not just disable) when conditions fail + 'conditions' => [], // item-level conditions + 'userConditions' => [], // user profile conditions + 'responseMessage' => [ + 'success' => 'Archived successfully', + 'error' => 'Archive failed', + ], + 'confirmationModalAttributes' => [ + 'title' => 'Confirm Archive', + ], + ], +] +``` + +## Action types + +| Type | Behaviour | +|------|-----------| +| `request` | Sends an Axios request to `endpoint` using `method`. Shows result via `useAlert`. | +| `modal` | Opens a form modal at `endpoint` (uses `store.commit('SET_MODAL', ...)`). | +| `download` | Opens `endpoint` in a new tab via a temporary `<a>` element. | +| `blank` | Opens `endpoint` in a new tab via `window.open`. | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `allActions` | `ComputedRef<Array>` | All actions after attribute casting and condition evaluation | +| `visibleActions` | `ComputedRef<Array>` | Actions that are not hidden and not disabled | +| `hasActions` | `ComputedRef<Boolean>` | True when `allActions.length > 0` | +| `hasVisibleActions` | `ComputedRef<Boolean>` | True when `visibleActions.length > 0` | +| `handleAction` | `(action) => void` | Execute an action; opens confirmation modal if `hasConfirmation` is true | +| `shouldShowAction` | `(action) => Boolean` | Validate action against `isEditing`, conditions, and user conditions | + +## Attribute casting + +Before rendering, each action's string values are processed with `useCastAttributes`. This lets you reference the editing item in labels and endpoints: + +```php +'label' => 'View $name', // item.name +'endpoint' => '/items/:id/review', // :id is replaced with item.id +``` + +## See Also + +- [useCastAttributes](/system-reference/frontend/composables/use-cast-attributes) — `$notation` interpolation +- [useDynamicModal](/system-reference/frontend/composables/use-dynamic-modal) — confirmation dialog +- [useAlert](/system-reference/frontend/composables/use-alert) — success/error notifications diff --git a/docs/src/pages/system-reference/frontend/composables/use-locale.md b/docs/src/pages/system-reference/frontend/composables/use-locale.md new file mode 100644 index 000000000..e5c7681ac --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-locale.md @@ -0,0 +1,61 @@ +--- +sidebarTitle: useLocale +--- + +# useLocale + +Provides reactive locale state, RTL detection, and a method to switch the active language. + +**File:** `vue/src/js/hooks/useLocale.js` + +--- + +## Usage + +```js +import { useLocale } from '@/hooks' + +const { + currentLocale, + languages, + hasLocale, + isLocaleRTL, + dirLocale, + displayedLocale, + updateLocale +} = useLocale() +``` + +```html +<div :dir="dirLocale"> + <v-select v-model="currentLocale" :items="languages" /> +</div> +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `currentLocale` | `ComputedRef<String>` | Active locale code (e.g. `'en'`, `'ar'`) from `store.state.user.locale` | +| `languages` | `ComputedRef<Array>` | List of available language objects from `store.state.config.languages` | +| `hasLocale` | `ComputedRef<Boolean>` | `true` when `languages` has more than one entry | +| `isLocaleRTL` | `ComputedRef<Boolean>` | `true` when the current locale is in the RTL locales list (Arabic, Hebrew, Persian, Urdu, etc.) | +| `dirLocale` | `ComputedRef<'rtl'\|'ltr'>` | `'rtl'` or `'ltr'` string, ready to bind to the `dir` attribute | +| `displayedLocale` | `ComputedRef<String>` | Display-friendly locale label (title-cased locale code or label from the languages list) | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `updateLocale` | `(locale: String) => void` | Update the Vuex store and vue-i18n locale to the given code | + +## Notes + +- RTL locales include: `ar`, `he`, `fa`, `ur`, `ps`, `ckb`, `dv`, `ug`, `yi`. +- `updateLocale` mutates both `store.state.user.locale` and the `i18n.global.locale` so all reactive locale-dependent values update simultaneously. + +## See Also + +- [useUser](/system-reference/frontend/composables/use-user) — authenticated user state (locale is stored on the user) diff --git a/docs/src/pages/system-reference/frontend/composables/use-media-items.md b/docs/src/pages/system-reference/frontend/composables/use-media-items.md new file mode 100644 index 000000000..3617bed9c --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-media-items.md @@ -0,0 +1,66 @@ +--- +sidebarTitle: useMediaItems +--- + +# useMediaItems + +Manages the selected item list inside the media library picker — tracking selection state, used items, and shift-click range selection. + +**File:** `vue/src/js/hooks/useMediaItems.js` + +--- + +## Usage + +```js +import { useMediaItems } from '@/hooks' + +const { + itemsLoading, + replacingMediaIds, + isSelected, + isUsed, + toggleSelection, + shiftToggleSelection +} = useMediaItems() +``` + +```html +<media-item + v-for="item in items" + :key="item.id" + :selected="isSelected(item)" + :used="isUsed(item)" + @click="toggleSelection(item)" + @shift-click="shiftToggleSelection(item, items)" +/> +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `itemsLoading` | `Ref<Boolean>` | `true` while media items are being fetched | +| `replacingMediaIds` | `Ref<Array<Number>>` | IDs of items currently being replaced (upload in progress) | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `isSelected` | `(item) => Boolean` | Returns `true` when `item` is in the current selection | +| `isUsed` | `(item) => Boolean` | Returns `true` when `item` is already attached to the form field | +| `toggleSelection` | `(item) => void` | Add or remove `item` from the selection | +| `shiftToggleSelection` | `(item, allItems) => void` | Shift-click range selection — selects all items between the last clicked item and `item` in `allItems` | + +## Notes + +- Selection state is local to the media library modal; it resets when the modal closes. +- `shiftToggleSelection` relies on the order of `allItems` matching the visual order in the grid. + +## See Also + +- [useMediaLibrary](/system-reference/frontend/composables/use-media-library) — open/close the media library modal +- [useFile](/system-reference/frontend/composables/use-file) — file field input state +- [useImage](/system-reference/frontend/composables/use-image) — image field input state diff --git a/docs/src/pages/system-reference/frontend/composables/use-media-library.md b/docs/src/pages/system-reference/frontend/composables/use-media-library.md new file mode 100644 index 000000000..424292b9b --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-media-library.md @@ -0,0 +1,61 @@ +--- +sidebarTitle: useMediaLibrary +--- + +# useMediaLibrary + +Opens the media library picker modal by committing the required Vuex mutations, allowing any component to trigger the media picker for a specific field. + +**File:** `vue/src/js/hooks/useMediaLibrary.js` + +--- + +## Usage + +```js +import { useMediaLibrary } from '@/hooks' + +const { openMediaLibrary } = useMediaLibrary() + +// Open the library to pick up to 3 images for the "gallery" field at index 0 +openMediaLibrary(3, 'gallery', 0, existingItems) +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `openMediaLibrary` | `(max, name, index?, initialItems?) => void` | Open the media library modal configured for a specific field | + +### `openMediaLibrary` Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `max` | `Number` | — | Maximum number of items that can be selected | +| `name` | `String` | — | The form field name this picker is bound to | +| `index` | `Number` | `null` | Repeater row index, when the field is inside a repeater | +| `initialItems` | `Array` | `[]` | Items already attached to the field, shown as pre-selected | + +## Behavior + +`openMediaLibrary` commits 8 Vuex mutations in sequence: + +1. Sets `mediaLibrary.open` to `true` +2. Sets `mediaLibrary.max` (max selectable items) +3. Sets `mediaLibrary.name` (target field name) +4. Sets `mediaLibrary.index` (repeater index) +5. Sets `mediaLibrary.initialItems` (pre-selected items) +6. Resets the current selection to `initialItems` +7. Clears any previous upload state +8. Resets the picker's page to 1 + +## Notes + +- The media library is a global modal; only one picker can be open at a time. +- When the user confirms their selection, the Vuex store emits an event that `useFile` / `useImage` listen to in order to update the form field value. + +## See Also + +- [useMediaItems](/system-reference/frontend/composables/use-media-items) — selection state inside the picker +- [useFile](/system-reference/frontend/composables/use-file) — file field input state +- [useImage](/system-reference/frontend/composables/use-image) — image field input state diff --git a/docs/src/pages/system-reference/frontend/composables/use-modal.md b/docs/src/pages/system-reference/frontend/composables/use-modal.md new file mode 100644 index 000000000..ad1173bcd --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-modal.md @@ -0,0 +1,95 @@ +--- +sidebarTitle: useModal +--- + +# useModal + +Manages modal open/close state, width, fullscreen toggle, and action callbacks. Used by the `UeModal` component and any component that wraps a Vuetify `v-dialog`. + +**File:** `vue/src/js/hooks/useModal.js` +**Props factories:** `makeModalProps`, `makeModalMediaProps` + +--- + +## Usage + +```js +import { useModal, makeModalProps } from '@/hooks' + +const props = defineProps({ ...makeModalProps() }) +const { dialog, openModal, closeModal, toggleModal, modalWidth } = useModal(props, context) +``` + +```html +<v-dialog v-model="dialog" :width="modalWidth"> + <slot /> + <template #actions> + <v-btn @click="closeModal">Cancel</v-btn> + <v-btn @click="confirmCallback?.()">Confirm</v-btn> + </template> +</v-dialog> +``` + +## Props (via `makeModalProps`) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Boolean` | — | External open state (`v-model`) | +| `useModelValue` | `Boolean` | `true` | When `false`, the modal manages its own `internalOpen` state | +| `title` | `String` | `null` | Modal title | +| `widthType` | `String` | `'md'` | `'xs'` \| `'sm'` \| `'md'` \| `'lg'` \| `'xl'` | +| `fullscreen` | `Boolean` | `false` | Open in full screen | +| `hasCloseButton` | `Boolean` | `false` | Show ✕ button in title bar | +| `hasFullscreenButton` | `Boolean` | `false` | Show fullscreen toggle | +| `hasTitleDivider` | `Boolean` | `false` | Divider below title | +| `noDefaultBodyPadding` | `Boolean` | `false` | Remove default body padding | +| `noActions` | `Boolean` | `false` | Hide the actions footer | +| `noCancelButton` | `Boolean` | `false` | Hide cancel button | +| `noConfirmButton` | `Boolean` | `false` | Hide confirm button | +| `description` | `String` | `null` | Description text | +| `cancelText` | `String` | `''` | Cancel button label | +| `confirmText` | `String` | `''` | Confirm button label | +| `confirmCallback` | `Function` | — | Called on confirm click | +| `rejectCallback` | `Function` | — | Called on cancel click | +| `confirmClosing` | `Boolean` | `true` | Close on confirm | +| `rejectClosing` | `Boolean` | `true` | Close on cancel | +| `transition` | `String` | `'bottom'` | Dialog transition type | + +### Width presets + +| `widthType` | Width | +|-------------|-------| +| `xs` | 320px | +| `sm` | 480px | +| `md` | 720px | +| `lg` | 1080px | +| `xl` | 1600px | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `dialog` | `ComputedRef<Boolean>` | Two-way binding — reads `modelValue` (or `internalOpen`), writes via `emit` | +| `full` | `Ref<Boolean>` | Fullscreen toggle state | +| `modalWidth` | `ComputedRef<String\|null>` | Resolved pixel width string, or `null` in fullscreen | +| `openModal` | `() => Boolean` | Sets `dialog` to `true` | +| `closeModal` | `() => Boolean` | Sets `dialog` to `false` | +| `toggleModal` | `() => Boolean` | Toggles `dialog` | +| `emitModelValue` | `(val) => void` | Emits `update:modelValue` | +| `emitOpened` | `() => void` | Emits `opened` event | +| `clickOutside` | `(event) => void` | Emits `click:outside` event | + +## `makeModalMediaProps` + +A secondary props factory for modal wrappers that embed the media library: + +| Prop | Type | Default | +|------|------|---------| +| `modalTitlePrefix` | `String` | `t('media-library.title')` | +| `btnLabelSingle` | `String` | `t('media-library.insert')` | +| `btnLabelUpdate` | `String` | `t('media-library.update')` | +| `btnLabelMulti` | `String` | `t('media-library.insert')` | + +## See Also + +- [useDynamicModal](/system-reference/frontend/composables/use-dynamic-modal) — inject-based global modal (no local props needed) diff --git a/docs/src/pages/system-reference/frontend/composables/use-model-value.md b/docs/src/pages/system-reference/frontend/composables/use-model-value.md new file mode 100644 index 000000000..f3e0daf4a --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-model-value.md @@ -0,0 +1,54 @@ +--- +sidebarTitle: useModelValue +--- + +# useModelValue + +A thin wrapper that creates a two-way computed property for `v-model` binding. Used in components that accept a `modelValue` prop and emit `update:modelValue`. + +**File:** `vue/src/js/hooks/useModelValue.js` +**Props factory:** `makeModelValueProps` + +--- + +## Usage + +```js +import { useModelValue, makeModelValueProps } from '@/hooks' + +const props = defineProps({ ...makeModelValueProps() }) +const { activeItem } = useModelValue(props, context) + +// Or with a custom name +const { selectedRow } = useModelValue(props, context, 'selectedRow') +``` + +```html +<child-component v-model="activeItem" /> +``` + +## Props (via `makeModelValueProps`) + +| Prop | Type | Description | +|------|------|-------------| +| `modelValue` | `String \| Number \| Object \| Boolean` | The bound value | + +## Returns + +A reactive object with a single key whose name is the `name` parameter (default `'activeItem'`): + +| Name | Type | Description | +|------|------|-------------| +| `[name]` | `ComputedRef` | Get returns `props.modelValue`; set emits `update:modelValue` | + +## Example with custom name + +```js +const { selectedUser } = useModelValue(props, context, 'selectedUser') +// selectedUser.value reads props.modelValue +// selectedUser.value = x → emits update:modelValue with x +``` + +## See Also + +- [useActiveTableItem](/system-reference/frontend/composables/use-active-table-item) — spreads `makeModelValueProps` for its own row selection diff --git a/docs/src/pages/system-reference/frontend/composables/use-module.md b/docs/src/pages/system-reference/frontend/composables/use-module.md new file mode 100644 index 000000000..8240182fd --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-module.md @@ -0,0 +1,85 @@ +--- +sidebarTitle: useModule +--- + +# useModule + +Derives i18n-translated module names, permission key, and layout metadata from a component's `name` / `moduleName` / `routeName` props. Used by table and page components to display localised module titles without hardcoding strings. + +**File:** `vue/src/js/hooks/useModule.js` +**Props factory:** `makeModuleProps` + +--- + +## Usage + +```js +import { useModule, makeModuleProps } from '@/hooks' + +const props = defineProps({ ...makeModuleProps() }) +const { + transNameSingular, + transNamePlural, + permissionName, + snakeName, + searchPlaceholder +} = useModule(props, context) +``` + +```html +<h1>{{ transNamePlural }}</h1> +<p>{{ searchPlaceholder }}</p> +``` + +## Props (via `makeModuleProps`) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `name` | `String` | — | Module or route name (used for i18n and permission key) | +| `customTitle` | `String` | — | Override the translated plural name | +| `titlePrefix` | `String` | `''` | Prepend a string to the title | +| `titleKey` | `String` | `'name'` | Key used for the display title | +| `fillHeight` | `Boolean` | `false` | Expand component to fill container height | +| `slots` | `Object` | `{}` | Slot overrides | +| `noFullScreen` | `Boolean` | `false` | Disable fullscreen toggle | +| `endpoints` | `Object` | `{}` | Endpoint URL map | +| `items` | `Array` | `[]` | Initial item list | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `snakeName` | `String` | `_.snakeCase(props.name)` | +| `moduleSnakeName` | `String` | `_.snakeCase(props.moduleName ?? props.name)` | +| `routeSnakeName` | `String` | `_.snakeCase(props.routeName ?? props.name)` | +| `tableTranslationNotation` | `String` | Full dot-notation i18n key, e.g. `modules.blog.posts.name` | +| `transNameSingular` | `ComputedRef<String>` | `t(notation, 0)` — singular form | +| `transNamePlural` | `ComputedRef<String>` | `t(notation, 1)` — plural form | +| `permissionName` | `ComputedRef<String>` | `_.kebabCase(name)` — used for permission checks | +| `searchPlaceholder` | `String` | `t('Type to Search')` | +| `searchModel` | `String` | Initial empty search string | +| `elements` | `Array` | Initial items from `props.items` | +| `windowSize` | `Object` | `{ x, y }` — updated by `onResize` | +| `onResize` | `Function` | Updates `windowSize` from `window.innerWidth/Height` | + +## Translation key structure + +The hook resolves i18n keys from `modules.*`: + +``` +// For a module named 'Blog' with route 'Post': +tableTranslationNotation = 'modules.blog.post.name' + +// In your lang file: +{ + modules: { + blog: { + post: { name: 'Post | Posts' } + } + } +} +``` + +## See Also + +- [useAuthorization](/system-reference/frontend/composables/use-authorization) — uses `permissionName` to scope permission checks diff --git a/docs/src/pages/system-reference/frontend/composables/use-navigation-layout.md b/docs/src/pages/system-reference/frontend/composables/use-navigation-layout.md new file mode 100644 index 000000000..6c6e658bc --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-navigation-layout.md @@ -0,0 +1,76 @@ +--- +sidebarTitle: useNavigationLayout +--- + +# useNavigationLayout + +Merges PHP-defined navigation config defaults with persisted user UI preferences to produce the final topbar and bottom-nav options. Used by `Main.vue` and `useSidebar` to determine which navigation elements to show and on which breakpoints. + +**File:** `vue/src/js/hooks/useNavigationLayout.js` + +--- + +## Usage + +```js +import { useNavigationLayout } from '@/hooks' + +const { + topbarOptions, + bottomNavOptions, + showTopbar, + showBottomNav, + persistUiPreferences +} = useNavigationLayout() +``` + +```html +<v-app-bar v-if="showTopbar" /> +<v-bottom-navigation v-if="showBottomNav" /> +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `topbarOptions` | `ComputedRef<Object>` | Merged topbar config (defaults + user prefs from `store.state.config.uiPreferences.topbar`) | +| `bottomNavOptions` | `ComputedRef<Object>` | Merged bottom-nav config (defaults + user prefs) | +| `showTopbar` | `ComputedRef<Boolean>` | True when topbar is enabled and should appear on the current breakpoint | +| `showBottomNav` | `ComputedRef<Boolean>` | True when bottom nav is enabled and should appear on the current breakpoint | +| `persistUiPreferences` | `async (preferences) => void` | Commits preferences to Vuex and PUTs them to `store.state.config.uiPreferencesEndpoint` | + +## Config shape + +Backend sends navigation options via Inertia shared data (`store.state.config.*`): + +```php +// In HandleInertiaRequests +'topbarOptions' => [ + 'enabled' => true, + 'fixed' => false, + 'showOnMobile' => true, + 'showOnDesktop' => true, +], +'bottomNavigationOptions' => [ + 'enabled' => false, + 'showOnMobile' => true, + 'showOnDesktop' => false, +], +'uiPreferencesEndpoint' => '/user/ui-preferences', +``` + +## Persisting preferences + +```js +// Save sidebar width and topbar visibility +await persistUiPreferences({ + topbar: { enabled: false }, + sidebar: { width: 300 } +}) +``` + +`persistUiPreferences` is a fire-and-forget call — it commits to Vuex immediately so the UI reacts, then attempts a PUT in the background. Failures are logged as warnings and do not revert the local state. + +## See Also + +- [useSidebar](/system-reference/frontend/composables/use-sidebar) — uses `persistUiPreferences` for rail and width persistence diff --git a/docs/src/pages/system-reference/frontend/composables/use-rand-key.md b/docs/src/pages/system-reference/frontend/composables/use-rand-key.md new file mode 100644 index 000000000..295b21a4a --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-rand-key.md @@ -0,0 +1,41 @@ +--- +sidebarTitle: useRandKey +--- + +# useRandKey + +Returns a unique integer key for the component instance, based on `Date.now()` combined with a random number. Use it as a `:key` binding to force a component to re-mount. + +**File:** `vue/src/js/hooks/useRandKey.js` + +--- + +## Usage + +```js +import { useRandKey } from '@/hooks' + +const key = useRandKey() +``` + +```html +<!-- Force re-mount when the schema changes --> +<form-base :key="key" :schema="schema" /> +``` + +## Returns + +A `Number` — `Date.now() + Math.floor(Math.random() * 9999)`. + +## Notes + +- This is not reactive — it returns a plain number, not a `ref`. Call it once per component instance. +- If you need a reactive key that changes on demand, wrap it in a `ref` and call `useRandKey()` when you need to reset: + +```js +const formKey = ref(useRandKey()) + +function resetForm() { + formKey.value = useRandKey() +} +``` diff --git a/docs/src/pages/system-reference/frontend/composables/use-repeater.md b/docs/src/pages/system-reference/frontend/composables/use-repeater.md new file mode 100644 index 000000000..206256888 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-repeater.md @@ -0,0 +1,101 @@ +--- +sidebarTitle: useRepeater +--- + +# useRepeater + +Manages a list of repeatable form blocks. Each block has its own scoped schema and model. Handles add, delete, duplicate, drag reorder, uniqueness constraints, and sync between the internal repeater representation and the flat `modelValue` array. + +**File:** `vue/src/js/hooks/useRepeater.js` +**Props factory:** `makeRepeaterProps` + +--- + +## Usage + +```js +import { useRepeater, makeRepeaterProps } from '@/hooks' + +const props = defineProps({ ...makeRepeaterProps() }) +const { + repeaterModels, + repeaterSchemas, + totalRepeats, + isAddible, + isDeletable, + addRepeaterBlock, + deleteRepeaterBlock, + duplicateRepeaterBlock, + onUpdateRepeaterModel +} = useRepeater(props, context) +``` + +## Props (via `makeRepeaterProps`) + +Extends `makeInputProps` and `makeDraggableProps` plus: + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Array` | `[]` | The current list of block data | +| `schema` | `Object` | `{}` | Input schema shared by all blocks | +| `max` | `Number` | `-1` | Maximum blocks (-1 = unlimited) | +| `min` | `Number` | `-1` | Minimum blocks (-1 = none required) | +| `label` | `String` | `''` | Repeater label | +| `singularLabel` | `String` | — | Label for a single block (used in add button) | +| `addButtonText` | `String` | `t('ADD NEW')` | Add button label | +| `hasButtonLabel` | `Boolean` | `false` | Append `singularLabel` to the add button | +| `noAddButton` | `Boolean` | `false` | Hide the add button | +| `noHeaders` | `Boolean` | `false` | Skip removing labels from block schema | +| `isUnique` | `Boolean` | `false` | Enforce unique values in the first field | +| `uniqueValue` | `String` | `'id'` | Key used to determine uniqueness | +| `uniqueField` | `String` | `null` | Schema field name that must be unique | +| `asObject` | `Boolean` | `false` | Store as `{ [uniqueField]: {rest} }` instead of an array | +| `formCol` | `Object` | `{ cols: 12 }` | Grid column for each block | +| `autoIdGenerator` | `Boolean` | `true` | Assign an `id` equal to the block index | +| `idResetter` | `String` | `null` | Field key — resets `id` when this field changes | +| `noWaitSourceLoading` | `Boolean` | `false` | Don't wait for source loading before rendering | + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `repeaterModels` | `Ref<Array>` | Internal hydrated model list (namespaced keys) | +| `repeaterSchemas` | `ComputedRef<Array>` | Per-block schema list with namespaced field names | +| `totalRepeats` | `ComputedRef<Number>` | Number of blocks | +| `hasRepeaterModels` | `ComputedRef<Boolean>` | True when at least one block exists | +| `isAddible` | `ComputedRef<Boolean>` | True when a new block can be added | +| `isDeletable` | `ComputedRef<Boolean>` | True when a block can be deleted | +| `addButtonIsActive` | `ComputedRef<Boolean>` | True when the add button should be enabled | +| `headers` | `Array` | Column header labels derived from the schema | +| `selectFieldSlots` | `ComputedRef<Array>` | Slot definitions for schema fields that declare `slots` | +| `hasSchemaInputSourceLoading` | `ComputedRef<Boolean>` | True while any schema input is loading remote data | +| `addRepeaterBlock` | `() => void` | Append a new blank block | +| `deleteRepeaterBlock` | `(index) => void` | Remove block at index | +| `duplicateRepeaterBlock` | `(index) => void` | Clone block at index | +| `onUpdateRepeaterModel` | `(value, index) => void` | Update a block's model when a field changes | +| `onUpdateRepeaterSchema` | `(value, index) => void` | Update raw schema when a field updates it (e.g. cascade) | + +## Field namespacing + +To isolate blocks from each other, every field name is namespaced: + +``` +repeater{id}[{blockIndex}][{fieldName}] +// e.g. repeater42[0][title] +``` + +The hook transparently hydrates (namespace) and parses (strip namespace) models on write and read. + +## Uniqueness mode + +When `isUnique: true`, each block's designated `uniqueField` must have a distinct value. The hook: +- Tracks `uniqueFilledValues` across all blocks. +- Filters the available items in the unique field's select down to unused values. +- Disables the add button when all available values are taken. +- Supports `asObject: true` to store as `{ [uniqueField]: { ...rest } }` instead of an array. + +## See Also + +- [input-repeater](/guide/form-inputs/input-repeater) +- [input-json-repeater](/guide/form-inputs/input-json-repeater) +- [useDraggable](/system-reference/frontend/composables/use-draggable) — reorder blocks by dragging diff --git a/docs/src/pages/system-reference/frontend/composables/use-root.md b/docs/src/pages/system-reference/frontend/composables/use-root.md new file mode 100644 index 000000000..4ba17f698 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-root.md @@ -0,0 +1,31 @@ +--- +sidebarTitle: useRoot +--- + +# useRoot + +Provides access to the Vuetify instance and the Vue root component instance after mount. This is a low-level hook used internally; most features it was originally designed for have been superseded by Vuetify's `useDisplay` and the Vuex store. + +**File:** `vue/src/js/hooks/useRoot.js` + +--- + +## Usage + +```js +import { useRoot } from '@/hooks' + +// Currently returns an empty object — see Notes +useRoot() +``` + +## Notes + +- The hook initialises `vuetifyInstance` and `rootInstance` in `onMounted`, but the reactive state and most methods in it are currently commented out. +- It is imported by `useFile` and `useImage` for historical reasons; the actual Vuetify display utilities used in those hooks come from `useDisplay` directly. +- **Do not rely on this hook** for Vuetify display breakpoints — use Vuetify's `useDisplay()` composable instead. + +## See Also + +- [useNavigationLayout](/system-reference/frontend/composables/use-navigation-layout) — uses `useDisplay` for responsive layout +- [useSidebar](/system-reference/frontend/composables/use-sidebar) — uses `useDisplay` for rail/expand behaviour diff --git a/docs/src/pages/system-reference/frontend/composables/use-sidebar.md b/docs/src/pages/system-reference/frontend/composables/use-sidebar.md new file mode 100644 index 000000000..87aa61f36 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-sidebar.md @@ -0,0 +1,95 @@ +--- +sidebarTitle: useSidebar +--- + +# useSidebar + +Manages the full sidebar state: open/close, rail mode, expand-on-hover behaviour, drag-to-resize, and user preference persistence. + +**File:** `vue/src/js/hooks/useSidebar.js` + +--- + +## Usage + +```js +import { useSidebar } from '@/hooks' + +const { + status, + rail, + width, + options, + expandHover, + handleRailToggle, + handleResizeStart, + handleResizing, + handleResizeEnd, + handleSidebarLeave +} = useSidebar() +``` + +```html +<v-navigation-drawer + v-model="status" + :rail="rail" + :width="width" + :permanent="effectivePermanent" + :temporary="effectiveTemporary" + @mousedown.native="handleResizeStart" +> + ... +</v-navigation-drawer> +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `status` | `ComputedRef<Boolean>` | Sidebar open/closed (read/write — synced with Vuex `config.sidebarStatus`) | +| `rail` | `ComputedRef<Boolean>` | True when in rail (mini) mode on `lgAndUp` | +| `railManual` | `Ref<Boolean>` | Local rail toggle state (persisted to user prefs) | +| `width` | `ComputedRef<Number>` | Current sidebar width in px | +| `railWidth` | `ComputedRef<Number>` | Rail-mode width (default `56`) | +| `options` | `ComputedRef<Object>` | Merged config + user preferences for the sidebar | +| `expandHover` | `ComputedRef<String>` | `'mini'` \| `'hidden'` — expand-on-hover strategy | +| `fullyHidden` | `ComputedRef<Boolean>` | True when `expandHover === 'hidden'` | +| `sidebarLocation` | `ComputedRef<String>` | `'left'` or `'right'` | +| `hideIcons` | `ComputedRef<Boolean>` | True when not in rail and `options.hideIcons` is set | +| `isHoverable` | `ComputedRef<Boolean>` | True when expand-on-hover is active | +| `sidebarPinned` | `ComputedRef<Boolean>` | Whether the user has pinned the sidebar open | +| `effectivePersistent` | `ComputedRef<Boolean>` | Whether the sidebar participates in Vuetify layout (narrows main content) | +| `effectivePermanent` | `ComputedRef<Boolean>` | Always visible and layout-aware on desktop in mini mode | +| `effectiveTemporary` | `ComputedRef<Boolean>` | Overlay mode (does not affect main content width) | +| `isResizing` | `Ref<Boolean>` | True while the user is dragging the resize handle | +| `open` | `Array` | Open state for nested navigation groups | +| `activeMenu` | `ComputedRef<String>` | Active menu item anchor (e.g. `'#profile'`) | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `handleRailToggle` | `() => void` | Toggle rail mode and persist the preference | +| `handleResizeStart` | `(e) => void` | Begin drag resize (mousedown on the resize handle) | +| `handleResizing` | `(e) => void` | Update sidebar width during drag (mousemove) | +| `handleResizeEnd` | `() => void` | Finish resize and persist the new width | +| `handleSidebarLeave` | `() => void` | Close the sidebar when in hidden mode and not pinned | +| `handleMenu` | `(title) => void` | Set `activeMenu` to `#title` | +| `handleProfile` | `(event) => void` | Expand profile section on hover if `expandOnHover` is configured | + +## Expand strategies + +| `expandHover` | Behaviour | +|---------------|-----------| +| `'mini'` | Sidebar is always visible (rail or expanded). Toggling rail narrows/expands while the sidebar stays in the Vuetify layout. | +| `'hidden'` | Sidebar is an overlay. It appears on hover over the left edge or when opened programmatically. Pinning it makes it persistent. | + +## Drag-to-resize + +The resize handle triggers `handleResizeStart` (mousedown). Global `mousemove` / `mouseup` listeners (added in `onMounted`) call `handleResizing` and `handleResizeEnd`. The width is clamped to `[256, 400]` px and persisted via `useNavigationLayout.persistUiPreferences`. + +## See Also + +- [useNavigationLayout](/system-reference/frontend/composables/use-navigation-layout) — topbar and bottom-nav config merging; provides `persistUiPreferences` diff --git a/docs/src/pages/system-reference/frontend/composables/use-svg.md b/docs/src/pages/system-reference/frontend/composables/use-svg.md new file mode 100644 index 000000000..915ef6177 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-svg.md @@ -0,0 +1,40 @@ +--- +sidebarTitle: useSvg +--- + +# useSvg + +Provides utilities for checking SVG symbol availability and resolving locale-aware SVG symbol names. + +**File:** `vue/src/js/hooks/useSvg.js` + +--- + +## Usage + +```js +import { useSvg } from '@/hooks' + +const { symbolExists, isHotSvg, getLocaleSymbol } = useSvg() + +if (symbolExists('icon-home')) { + // render SVG symbol +} + +// Get a locale-specific variant, falling back to a default +const symbol = getLocaleSymbol('flag', 'flag-default') +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `symbolExists` | `(symbol: String) => Boolean` | Returns `true` if the given SVG symbol ID exists in the page's SVG sprite | +| `isHotSvg` | `() => Boolean` | Returns `true` when SVG hot-reload mode is active (development only) | +| `getLocaleSymbol` | `(symbol: String, fallback: String) => String` | Returns a locale-specific symbol name, falling back to `fallback` if the locale variant doesn't exist | + +## Notes + +- The underlying utilities are in `vue/src/js/utils/svg.js`. +- `symbolExists` is useful for conditionally rendering an SVG icon vs. a Vuetify MDI fallback. +- `getLocaleSymbol` is primarily used for flag icons or other locale-sensitive imagery. diff --git a/docs/src/pages/system-reference/frontend/composables/use-table.md b/docs/src/pages/system-reference/frontend/composables/use-table.md new file mode 100644 index 000000000..3086f4a88 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-table.md @@ -0,0 +1,92 @@ +--- +sidebarTitle: useTable +--- + +# useTable + +Main data-table composable. Orchestrates all table sub-hooks, loads items from the server, and provides the full API consumed by the `DataTable` component. + +**File:** `vue/src/js/hooks/useTable.js` + +--- + +## Props Factory + +```js +import { makeTableProps } from '@/hooks/useTable' +``` + +Key props (assembled from all sub-hook `makeXxxProps` factories): + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `endpoints` | `Object` | `{}` | URL map: `index`, `store`, `update`, `destroy`, `forceDelete`, `restore`, `show` | +| `columns` | `Array` | `[]` | Column definitions — see [useTableHeaders](/system-reference/frontend/composables/table/use-table-headers) | +| `formSchema` | `Object` | required | Schema for the create/edit form | +| `rowActions` | `Array\|Object` | `[]` | Per-row action definitions — see [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) | +| `filterList` | `Array` | `[]` | Status filter tabs | +| `filterListAdvanced` | `Object` | `{}` | Advanced filter panel definitions | +| `name` | `String` | — | Module name used for i18n and permissions | +| `moduleName` | `String` | — | Override module name for permissions | +| `createOnModal` | `Boolean` | `true` | Open the create form in a modal | +| `editOnModal` | `Boolean` | `true` | Open the edit form in a modal | +| `itemsPerPage` | `Number` | `20` | Default items per page | +| `actions` | `Array` | `[]` | Table-level bulk/toolbar actions | + +## Usage + +```js +import { useTable, makeTableProps } from '@/hooks/useTable' + +const props = defineProps(makeTableProps()) +const emit = defineEmits(['update:modelValue']) + +const table = useTable(props, { emit }) +``` + +## Returns + +`useTable` spreads the return values of all sub-hooks. Key top-level additions: + +| Name | Type | Description | +|------|------|-------------| +| `elements` | `Ref<Array>` | The current page of table rows | +| `totalElements` | `Ref<Number>` | Total record count (for pagination) | +| `tableLoading` | `Ref<Boolean>` | `true` while items are being loaded | +| `loadItems` | `() => Promise` | Fetch items from `endpoints.index` using the current filter/sort/page state | +| `sortElements` | `(key, direction) => void` | Sort the table by a column | +| `dataTableRowProps` | `(item) => Object` | Returns Vuetify row props (click handlers, classes) for each row | +| `headersForDataTable` | `ComputedRef<Array>` | Processed column definitions ready for `v-data-table` | +| `options` | `Ref<Object>` | Vuetify data-table options object (page, itemsPerPage, sortBy, groupBy) | + +## Sub-hooks Orchestrated + +`useTable` composes the following sub-hooks internally: + +| Sub-hook | Responsibility | +|----------|---------------| +| [useTableState](/system-reference/frontend/composables/table/use-table-state) | URL/localStorage state persistence | +| [useTableItem](/system-reference/frontend/composables/table/use-table-item) | Edited item and soft-delete detection | +| [useTableNames](/system-reference/frontend/composables/table/use-table-names) | i18n titles and delete dialog text | +| [useTableFilters](/system-reference/frontend/composables/table/use-table-filters) | Search, status tabs, advanced filters | +| [useTableHeaders](/system-reference/frontend/composables/table/use-table-headers) | Column visibility, localStorage persistence | +| [useTableForms](/system-reference/frontend/composables/table/use-table-forms) | Create/edit form state | +| [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) | Row action dispatch | +| [useTableModals](/system-reference/frontend/composables/table/use-table-modals) | Delete/custom/show modals | +| [useTableActions](/system-reference/frontend/composables/table/use-table-actions) | Table-level toolbar actions | +| [useTableGroup](/system-reference/frontend/composables/table/use-table-group) | Client-side column grouping | + +## Data Loading + +`loadItems` builds an axios GET request to `endpoints.index` including: +- Current page, items-per-page, and sort-by from `options` +- Active search string +- Active status filter slug +- Active advanced filters + +Results are stored in `elements` and `totalElements`. + +## See Also + +- [useActiveTableItem](/system-reference/frontend/composables/use-active-table-item) — active row / detail-panel state +- [useFormatter](/system-reference/frontend/composables/use-formatter) — column value formatters diff --git a/docs/src/pages/system-reference/frontend/composables/use-user.md b/docs/src/pages/system-reference/frontend/composables/use-user.md new file mode 100644 index 000000000..6adf44312 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-user.md @@ -0,0 +1,67 @@ +--- +sidebarTitle: useUser +--- + +# useUser + +Provides reactive state about the authenticated user — guest status, roles, timezone, company validity — and proxies the authorization helpers from `useAuthorization`. + +**File:** `vue/src/js/hooks/useUser.js` + +--- + +## Usage + +```js +import { useUser } from '@/hooks' + +const { + isGuest, + isSuperAdmin, + isClient, + timezone, + validCompany, + showBillingBanner, + can, + isYou, + hasRoles +} = useUser() +``` + +```html +<template v-if="!isGuest"> + <span>Welcome back</span> +</template> + +<v-btn v-if="can('manage-billing')">Billing</v-btn> +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `isGuest` | `ComputedRef<Boolean>` | `true` when no user is authenticated (`store.getters.isGuest`) | +| `isSuperAdmin` | `ComputedRef<Boolean>` | `true` when the user has the super-admin role | +| `isClient` | `ComputedRef<Boolean>` | `true` when the user has the client role | +| `timezone` | `ComputedRef<String>` | User's timezone string (e.g. `'Europe/Istanbul'`), defaults to `'Europe/London'` | +| `validCompany` | `ComputedRef<Boolean>` | `true` when the user's company has passed validation (`store.state.user.valid_company`) | +| `showBillingBanner` | `ComputedRef<Boolean>` | `true` when the billing banner should be displayed (`store.state.user.profile.show_billing_banner`) | + +### Proxied from `useAuthorization` + +| Name | Signature | Description | +|------|-----------|-------------| +| `can` | `(permission, moduleName?) => Boolean` | Permission check — see [useAuthorization](/system-reference/frontend/composables/use-authorization) | +| `isYou` | `(id) => Boolean` | Returns `true` when `id` matches the authenticated user's id | +| `hasRoles` | `(roles: String\|Array) => Boolean` | Returns `true` when the user has at least one of the given roles | + +## Notes + +- `useUser` is a convenience wrapper — it combines Vuex user state with the authorization helpers so components only need one import instead of two. +- For permission-only logic, prefer `useAuthorization` directly to keep the dependency minimal. + +## See Also + +- [useAuthorization](/system-reference/frontend/composables/use-authorization) — full permission and role API diff --git a/docs/src/pages/system-reference/frontend/composables/use-validation.md b/docs/src/pages/system-reference/frontend/composables/use-validation.md new file mode 100644 index 000000000..dd8469452 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/use-validation.md @@ -0,0 +1,96 @@ +--- +sidebarTitle: useValidation +--- + +# useValidation + +Provides 30+ validation rule factories, a rule-string-to-function converter, and helpers to generate a complete rule array for a form input. + +**File:** `vue/src/js/hooks/useValidation.js` + +--- + +## Usage + +```js +import { useValidation } from '@/hooks' + +const { generateInputRules, invokeRuleGenerator } = useValidation() + +// Generate rules from a schema field definition +const rules = generateInputRules(schemaField) + +// Convert a rule string into a callable rule function +const rule = invokeRuleGenerator('required') +``` + +```html +<v-text-field :rules="generateInputRules(field)" /> +``` + +## Returns + +### Core Helpers + +| Name | Signature | Description | +|------|-----------|-------------| +| `generateInputRules` | `(field) => Array<Function>` | Reads `field.rules` (array of strings or objects) and returns an array of Vuetify-compatible rule functions | +| `invokeRuleGenerator` | `(rule: String\|Object) => Function` | Resolves a rule name string (e.g. `'required'`) to the corresponding rule factory and returns the callable rule function | +| `validateInput` | `(value, rules) => Boolean\|String` | Runs a value through an array of rules; returns `true` on success or the first error message string | + +### Rule Factories + +All rule factories return `(value) => true | errorMessage`. + +| Rule | Description | +|------|-------------| +| `required` | Value must not be empty / null | +| `email` | Must be a valid e-mail address | +| `phone` | Must be a valid phone number | +| `url` | Must be a valid URL | +| `date` | Must be a parseable date string | +| `min(n)` | String/array length or numeric value ≥ n | +| `max(n)` | String/array length or numeric value ≤ n | +| `minLength(n)` | String length ≥ n | +| `maxLength(n)` | String length ≤ n | +| `minValue(n)` | Numeric value ≥ n | +| `maxValue(n)` | Numeric value ≤ n | +| `numeric` | Must be a number | +| `integer` | Must be an integer | +| `alpha` | Letters only | +| `alphaNum` | Letters and digits only | +| `password` | Must meet password complexity requirements | +| `confirmed(field)` | Must equal the value of another field | +| `unique` | Must be unique (resolved via async endpoint) | +| `regex(pattern)` | Must match the given regex | +| `sameAs(other)` | Must equal `other` | +| `notSameAs(other)` | Must not equal `other` | +| `between(min, max)` | Value must be between min and max | +| `decimal` | Must be a decimal number | +| `ipAddress` | Must be a valid IPv4 or IPv6 address | +| `macAddress` | Must be a valid MAC address | +| `json` | Must be valid JSON | +| `accepted` | Must be truthy (checkbox accepted) | +| `requiredIf(condition)` | Required when `condition` is true | +| `requiredUnless(condition)` | Required unless `condition` is true | +| `maxFileSize(kb)` | File size must not exceed `kb` kilobytes | +| `mimes(types)` | File MIME type must be in `types` list | + +## Schema Rule Format + +Rules in a schema field are expressed as strings or objects: + +```js +{ + rules: [ + 'required', + { name: 'minLength', params: [3] }, + { name: 'maxLength', params: [100] } + ] +} +``` + +## See Also + +- [useInput](/system-reference/frontend/composables/use-input) — base input state that consumes `generateInputRules` +- [useForm](/system-reference/frontend/composables/use-form) — top-level form validation orchestration diff --git a/docs/src/pages/system-reference/frontend/composables/utils/overview.md b/docs/src/pages/system-reference/frontend/composables/utils/overview.md new file mode 100644 index 000000000..8eaf1e786 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/utils/overview.md @@ -0,0 +1,15 @@ +--- +sidebarPos: 1 +sidebarTitle: Overview +--- + +# Utility Sub-hooks + +Four small utility composables live under `vue/src/js/hooks/utils/`. They are used internally by other hooks but can be imported directly when needed. + +| Sub-hook | File | Purpose | +|----------|------|---------| +| [useBadge](/system-reference/frontend/composables/utils/use-badge) | `useBadge.js` | Badge visibility and props for action buttons | +| [useGenerate](/system-reference/frontend/composables/utils/use-generate) | `useGenerate.js` | Button prop generation with Inertia-aware href handling | +| [usePagination](/system-reference/frontend/composables/utils/use-pagination) | `usePagination.js` | Infinite-scroll / load-more pagination state | +| [useSelect](/system-reference/frontend/composables/utils/use-select) | `useSelect.js` | Select input prop definitions (`makeSelectProps`) | diff --git a/docs/src/pages/system-reference/frontend/composables/utils/use-badge.md b/docs/src/pages/system-reference/frontend/composables/utils/use-badge.md new file mode 100644 index 000000000..5471a6a83 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/utils/use-badge.md @@ -0,0 +1,45 @@ +--- +sidebarTitle: useBadge +--- + +# useBadge + +Determines whether an action button should show a badge and returns the badge props to spread onto `<v-badge>`. + +**File:** `vue/src/js/hooks/utils/useBadge.js` + +--- + +## Usage + +```js +import useBadge from '@/hooks/utils/useBadge' + +const { isBadge, badgeProps } = useBadge(props, context) +``` + +```html +<v-badge v-if="isBadge(action)" v-bind="badgeProps(action)"> + <v-icon>{{ action.icon }}</v-icon> +</v-badge> +``` + +## Returns + +| Name | Signature | Description | +|------|-----------|-------------| +| `isBadge` | `(action: Object) => Boolean` | Returns `true` when the action has a `badge` property with a truthy / non-zero value | +| `badgeProps` | `(action: Object) => Object` | Returns `{ content, color, textColor }` ready to spread onto `<v-badge>` | + +## Badge Action Fields + +| Field | Type | Description | +|-------|------|-------------| +| `badge` | `Boolean\|Number\|String` | Badge visibility / content. Numeric strings are parsed — `'0'` hides the badge. | +| `badgeContent` | `any` | Badge label (falls back to `badge` if not set) | +| `badgeColor` | `String` | Badge background color (default `'warning'`) | +| `badgeTextColor` | `String` | Badge text color (default `'white'`) | + +## See Also + +- [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) — action dispatch that uses `useBadge` diff --git a/docs/src/pages/system-reference/frontend/composables/utils/use-generate.md b/docs/src/pages/system-reference/frontend/composables/utils/use-generate.md new file mode 100644 index 000000000..07bf06841 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/utils/use-generate.md @@ -0,0 +1,71 @@ +--- +sidebarTitle: useGenerate +--- + +# useGenerate + +Generates Vuetify button props from an action definition, with Inertia-aware `href` handling and responsive icon-only collapse on mobile. + +**File:** `vue/src/js/hooks/utils/useGenerate.js` + +--- + +## Usage + +```js +import useGenerate from '@/hooks/utils/useGenerate' + +const { generateButtonProps, generatedButtonProps } = useGenerate(props, context) +``` + +```html +<!-- Spread onto any v-btn --> +<v-btn v-bind="generateButtonProps(action)">{{ action.label }}</v-btn> + +<!-- Or use the reactive computed version (reads from props directly) --> +<v-btn v-bind="generatedButtonProps">{{ props.label }}</v-btn> +``` + +## Returns + +| Name | Type | Description | +|------|------|-------------| +| `generateButtonProps` | `(action: Object) => Object` | Generate button props from an action definition object | +| `generatedButtonProps` | `ComputedRef<Object>` | Reactive button props computed from `props` (used when the component itself is an action) | + +## Generated Props + +`generateButtonProps(action)` returns: + +| Prop | Source | Description | +|------|--------|-------------| +| `icon` | `action.icon` (when `!forceLabel`) | Icon to display | +| `text` | `action.label` (when `forceLabel`) | Text label | +| `color` | `action.color` | Button color | +| `variant` | `action.variant` | Vuetify variant | +| `density` | `action.density` | Default `'comfortable'` | +| `size` | `action.size` | Default `'default'` | +| `disabled` | `action.disabled` | Disabled state | +| `rounded` | `true` (icon), `null` (label) | Rounded style | +| `onClick` | Inertia-aware handler | Set when `action.href` is provided | + +## Inertia Href Handling + +When `action.href` is set, the generated `onClick` handler: + +1. Calls `e.preventDefault()` on the click event +2. If `shouldUseInertia` is `true` and the URL is on the same origin → `router.visit(href)` +3. If target is not `'_blank'` → `router.visit(href, { target })` +4. Otherwise → `window.open(href, target)` + +## Responsive Behavior + +`generatedButtonProps` collapses to icon-only on `xs` screens (Vuetify's `smAndUp` breakpoint is `false`): +- Sets `icon` to the resolved icon value +- Switches `density` to `'compact'` +- Sets `rounded: true` + +## See Also + +- [useConfig](/system-reference/frontend/composables/use-config) — provides `shouldUseInertia` +- [useTableItemActions](/system-reference/frontend/composables/table/use-table-item-actions) — calls `generateButtonProps` for row actions diff --git a/docs/src/pages/system-reference/frontend/composables/utils/use-pagination.md b/docs/src/pages/system-reference/frontend/composables/utils/use-pagination.md new file mode 100644 index 000000000..68028f95e --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/utils/use-pagination.md @@ -0,0 +1,100 @@ +--- +sidebarTitle: usePagination +--- + +# usePagination + +Manages infinite-scroll / load-more pagination state — page tracking, element accumulation, search, and URL construction. + +**File:** `vue/src/js/hooks/utils/usePagination.js` + +--- + +## Props Factory + +```js +import { makePaginationProps } from '@/hooks/utils/usePagination' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `endpoint` | `String` | — | Base URL for the paginated data source | +| `page` | `Number` | `1` | Initial page number | +| `lastPage` | `Number` | `-1` | Last known page number (`-1` = unknown) | +| `itemsPerPage` | `Number` | `20` | Items per page | +| `sourceLoading` | `Boolean` | `false` | External loading flag | +| `with` | `Array` | `[]` | Relationships to eager-load | +| `scopes` | `Array` | `[]` | Query scopes to apply | +| `orders` | `Array` | `[]` | Sort order definitions | +| `appends` | `Array` | `[]` | Appended attributes | +| `column` | `Array` | `[]` | Columns to select | +| `searchKeys` | `Array` | `['name']` | Fields to search within | +| `paginationPageKey` | `String` | `'page'` | Query parameter name for the page number | + +## Usage + +```js +import { usePagination, makePaginationProps } from '@/hooks/utils/usePagination' + +const props = defineProps(makePaginationProps()) + +const { + elements, + activePage, + nextPage, + activeLastPage, + itemsLoading, + fullUrl, + searchModel, + appendElements, + setActivePage, + setActiveLastPage, + setItemsLoading, +} = usePagination(props, context) +``` + +## Returns + +### State + +| Name | Type | Description | +|------|------|-------------| +| `elements` | `Ref<Array>` | Accumulated list of loaded items | +| `activePage` | `Ref<Number>` | Current page number | +| `nextPage` | `Ref<Number>` | Page to load next | +| `activeLastPage` | `Ref<Number>` | Last known page number | +| `itemsLoading` | `Ref<Boolean>` | `true` while a fetch is in progress | +| `searchModel` | `Ref<String>` | Search input value | +| `search` | `Ref<String>` | Committed search value | + +### Computed + +| Name | Type | Description | +|------|------|-------------| +| `fullUrl` | `ComputedRef<String>` | Complete URL with all query parameters | +| `queryParameters` | `ComputedRef<String>` | URLSearchParams string (page, itemsPerPage, search, with, …) | +| `searchFilterObject` | `ComputedRef<Object>` | `{ search: value }` when search is non-empty | +| `searchFieldsFilter` | `ComputedRef<Object>` | Multi-key search filter object | +| `defaultQueryParameters` | `Ref<Object>` | Parameters parsed from the original `endpoint` URL | + +### Methods + +| Name | Signature | Description | +|------|-----------|-------------| +| `setActivePage` | `(page) => void` | Update `activePage` and advance `nextPage` | +| `setActiveLastPage` | `(page) => void` | Update `activeLastPage` | +| `setItemsLoading` | `(value) => void` | Set the loading flag | +| `setElements` | `(items) => void` | Replace the elements list | +| `appendElements` | `(items) => void` | Append items to the end of `elements` | +| `prependElements` | `(items) => void` | Prepend items to the beginning of `elements` | +| `setSearchValue` | `(value?) => void` | Commit a search value | + +## Notes + +- For infinite-scroll UIs, call `appendElements` on each successful page load and `setActivePage` to advance. +- For standard paginated tables, use `setElements` to replace the list on each load. +- The main data-table uses [useTable](/system-reference/frontend/composables/use-table) directly. `usePagination` is intended for simpler paginated list components. + +## See Also + +- [useTable](/system-reference/frontend/composables/use-table) — full-featured table composable diff --git a/docs/src/pages/system-reference/frontend/composables/utils/use-select.md b/docs/src/pages/system-reference/frontend/composables/utils/use-select.md new file mode 100644 index 000000000..6c78547b2 --- /dev/null +++ b/docs/src/pages/system-reference/frontend/composables/utils/use-select.md @@ -0,0 +1,52 @@ +--- +sidebarTitle: useSelect +--- + +# useSelect + +Provides a `makeSelectProps` factory defining the standard prop set for select-type inputs — items, value/title keys, multiple selection, and object return behavior. + +**File:** `vue/src/js/hooks/utils/useSelect.js` + +--- + +## Props Factory + +```js +import { makeSelectProps } from '@/hooks/utils/useSelect' +``` + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `itemValue` | `String` | `'id'` | The item property used as the option value | +| `itemTitle` | `String` | `'name'` | The item property used as the option label | +| `multiple` | `Boolean` | `false` | Allow multiple selections | +| `items` | `Array` | `[]` | The list of selectable options | +| `returnObject` | `Boolean` | `false` | When `true`, emit the full object instead of just `itemValue` | +| `objectIdDefiner` | `String` | — | Property that uniquely identifies an object when `returnObject` is `true` | +| `convertObject` | `Boolean` | `false` | Auto-convert object values to ID values on emit | +| `objectModelValues` | `Array` | `['*']` | Which object properties to include when `convertObject` is `true`. `['*']` means all. | +| `max` | `Number` | `null` | Maximum number of selectable items (for multiple mode) | + +## Usage + +```js +import useSelect, { makeSelectProps } from '@/hooks/utils/useSelect' + +const props = defineProps({ + ...makeSelectProps(), + // additional props +}) + +useSelect(props, context) +``` + +## Notes + +- `makeSelectProps` is used by all select-type input components (`InputSelect`, `InputSelectScroll`, etc.) to share a consistent prop API. +- `returnObject` / `convertObject` / `objectModelValues` control the shape of emitted values when options are objects — allowing fine-grained control over what gets stored in the form model. + +## See Also + +- [useInputFetch](/system-reference/frontend/composables/use-input-fetch) — remote data loading for select inputs +- [useInput](/system-reference/frontend/composables/use-input) — base input composable used alongside `useSelect` diff --git a/docs/src/pages/system-reference/frontend.md b/docs/src/pages/system-reference/frontend/overview.md similarity index 54% rename from docs/src/pages/system-reference/frontend.md rename to docs/src/pages/system-reference/frontend/overview.md index 171f8243a..c691760b7 100644 --- a/docs/src/pages/system-reference/frontend.md +++ b/docs/src/pages/system-reference/frontend/overview.md @@ -63,6 +63,8 @@ const component = mapTypeToComponent('my-input') // => 'VMyInput' ## Hooks +For the full hook reference including all 38 composables, see **[Vue Hooks](/system-reference/frontend/composables/overview)**. + | Hook | Purpose | |------|---------| | useForm | Form state, validation, submit, schema/model sync | @@ -74,6 +76,28 @@ const component = mapTypeToComponent('my-input') // => 'VMyInput' | useCurrency, useCurrencyNumber | Currency formatting | | useMediaLibrary, useMediaItems | Media selection | | useConfig, useUser, useLocale | App state | +| [useAlert](/system-reference/frontend/composables/use-alert) | Global alert notifications | +| [useAuthorization](/system-reference/frontend/composables/use-authorization) | Permission and role checks | +| [useCache](/system-reference/frontend/composables/use-cache) | Client-side key-value cache | +| [useCastAttributes](/system-reference/frontend/composables/use-cast-attributes) | Dynamic `$notation` attribute interpolation | +| [useDraggable](/system-reference/frontend/composables/use-draggable) | Sortable drag-and-drop props | +| [useDynamicModal](/system-reference/frontend/composables/use-dynamic-modal) | Inject-based global modal service | +| [useFile](/system-reference/frontend/composables/use-file) / [useImage](/system-reference/frontend/composables/use-image) | File / image media-library inputs | +| [useFilepond](/system-reference/frontend/composables/use-filepond) | FilePond upload props and rules | +| [useFormatter](/system-reference/frontend/composables/use-formatter) | Table column value formatters | +| [useInertiaRequests](/system-reference/frontend/composables/use-inertia-requests) | In-flight Inertia request state | +| [useInputFetch](/system-reference/frontend/composables/use-input-fetch) | Paginated remote fetch for select inputs | +| [useInputHandlers](/system-reference/frontend/composables/use-input-handlers) | Slot-driven input click handlers | +| [useItemActions](/system-reference/frontend/composables/use-item-actions) | Form action buttons | +| [useModal](/system-reference/frontend/composables/use-modal) | Modal open/close, width, fullscreen | +| [useModelValue](/system-reference/frontend/composables/use-model-value) | `v-model` two-way binding helper | +| [useModule](/system-reference/frontend/composables/use-module) | Module i18n name and permission key | +| [useNavigationLayout](/system-reference/frontend/composables/use-navigation-layout) | Topbar / bottom-nav config | +| [useRandKey](/system-reference/frontend/composables/use-rand-key) | Unique component instance key | +| [useRepeater](/system-reference/frontend/composables/use-repeater) | Repeater block state management | +| [useSidebar](/system-reference/frontend/composables/use-sidebar) | Sidebar open/close, rail, resize | +| [useSvg](/system-reference/frontend/composables/use-svg) | SVG symbol utilities | +| [useActiveTableItem](/system-reference/frontend/composables/use-active-table-item) | Active row / detail-panel state | ## Utils @@ -92,4 +116,4 @@ API modules: `store/api/datatable.js`, `store/api/form.js`, `store/api/media-lib ## Schema Contract -See [Hydrates](./hydrates#schema-contract) for common schema keys. Frontend receives schema via Inertia; FormBase flattens and combines with model before rendering. +See [Hydrates](../hydrates#schema-contract) for common schema keys. Frontend receives schema via Inertia; FormBase flattens and combines with model before rendering. diff --git a/docs/src/pages/system-reference/hydrates.md b/docs/src/pages/system-reference/hydrates.md index fcf0512ee..44f6c9533 100644 --- a/docs/src/pages/system-reference/hydrates.md +++ b/docs/src/pages/system-reference/hydrates.md @@ -25,23 +25,38 @@ FormBase/FormBaseField → mapTypeToComponent('input-checklist') → VInputCheck | Config type | Hydrate class | Output type (schema) | Vue component | |-------------|---------------|----------------------|---------------| -| assignment | AssignmentHydrate | input-assignment | VInputAssignment | +| [assignment](/guide/form-inputs/input-assignment) | AssignmentHydrate | input-assignment | VInputAssignment | | authorize | AuthorizeHydrate | select | v-select (Vuetify) | -| chat | ChatHydrate | input-chat | VInputChat | -| checklist | ChecklistHydrate | input-checklist | VInputChecklist | +| [autocomplete](/guide/form-inputs/input-autocomplete) | AutocompleteHydrate | select / input-select-scroll | v-autocomplete (Vuetify) | +| [browser](/guide/form-inputs/input-browser) | BrowserHydrate | input-browser | VInputBrowser | +| [chat](/guide/form-inputs/input-chat) | ChatHydrate | input-chat | VInputChat | +| [checkbox](/guide/form-inputs/input-checkbox) | CheckboxHydrate | checkbox | v-checkbox (Vuetify) | +| [checklist](/guide/form-inputs/input-checklist) | ChecklistHydrate | input-checklist | VInputChecklist | +| [checklist-group](/guide/form-inputs/input-checklist-group) | ChecklistGroupHydrate | input-checklist-group | VInputChecklistGroup | +| [combobox](/guide/form-inputs/input-combobox) | ComboboxHydrate | combobox / input-select-scroll | v-combobox (Vuetify) | +| [comparison-table](/guide/form-inputs/input-comparison-table) | ComparisonTableHydrate | input-comparison-table | VInputComparisonTable | | creator | CreatorHydrate | input-browser | VInputBrowser | -| date | DateHydrate | input-date | VInputDate | -| file | FileHydrate | input-file | VInputFile | +| [date](/guide/form-inputs/input-date) | DateHydrate | input-date | VInputDate | +| [file](/guide/form-inputs/input-file) | FileHydrate | input-file | VInputFile | | filepond | FilepondHydrate | input-filepond | VInputFilepond | -| image | ImageHydrate | input-image | VInputImage | -| payment-service | PaymentServiceHydrate | input-payment-service | VInputPaymentService | -| price | PriceHydrate | input-price | VInputPrice | -| process | ProcessHydrate | input-process | VInputProcess | -| repeater | RepeaterHydrate | input-repeater | VInputRepeater | +| [filepond-avatar](/guide/form-inputs/input-filepond-avatar) | FilepondAvatarHydrate | input-filepond-avatar | VInputFilepondAvatar | +| [form-tabs (tab-group)](/guide/form-inputs/input-form-tabs) | FormTabsHydrate | input-form-tabs | VInputFormTabs | +| [image](/guide/form-inputs/input-image) | ImageHydrate | input-image | VInputImage | +| [json](/guide/form-inputs/input-json) | JsonHydrate | group | (group layout) | +| [json-repeater](/guide/form-inputs/input-json-repeater) | JsonRepeaterHydrate | input-repeater | VInputRepeater | +| [payment-service](/guide/form-inputs/input-payment-service) | PaymentServiceHydrate | input-payment-service | VInputPaymentService | +| [price](/guide/form-inputs/input-price) | PriceHydrate | input-price | VInputPrice | +| [process](/guide/form-inputs/input-process) | ProcessHydrate | input-process | VInputProcess | +| [radio-group](/guide/form-inputs/input-radio-group) | RadioGroupHydrate | input-radio-group | VInputRadioGroup | +| [repeater](/guide/form-inputs/input-repeater) | RepeaterHydrate | input-repeater | VInputRepeater | +| [relationships](/guide/form-inputs/input-relationships) | RelationshipsHydrate | input-relationships | VInputRelationships ⚠️ | | select | SelectHydrate | select | v-select (Vuetify) | -| spread | SpreadHydrate | input-spread | VInputSpread | +| [select-scroll](/guide/form-inputs/input-select-scroll) | SelectScrollHydrate | input-select-scroll | VInputSelectScroll | +| [spread](/guide/form-inputs/input-spread) | SpreadHydrate | input-spread | VInputSpread | | stateable | StateableHydrate | select | v-select (Vuetify) | -| tagger | TaggerHydrate | input-tagger | VInputTagger | +| [switch](/guide/form-inputs/input-switch) | SwitchHydrate | input-switch | VInputSwitch | +| [tag](/guide/form-inputs/input-tag) | TagHydrate | input-tag | VInputTag | +| [tagger](/guide/form-inputs/input-tagger) | TaggerHydrate | input-tagger | VInputTagger | | ... | ... | input-{kebab} | VInput{Studly} | **Rule**: `studlyName($input['type']) . 'Hydrate'` → class in `src/Hydrates/Inputs/` @@ -92,4 +107,4 @@ FormBase/FormBaseField → mapTypeToComponent('input-checklist') → VInputCheck - Component registers as `VInput{Studly}` via `includeFormInputs` glob 3. **Registry** (optional): Add to `hydrateTypeMap` in `registry.js` for explicit mapping -See the [create-input-hydrate](/guide/commands/Generators/create-input-hydrate) and [create-vue-input](/guide/commands/Generators/create-vue-input) commands. +See the [create-input-hydrate](/guide/console/generators/create-input-hydrate) and [create-vue-input](/guide/console/generators/create-vue-input) commands. diff --git a/docs/src/pages/system-reference/modules.md b/docs/src/pages/system-reference/modules.md index 726bd9755..2278fe5f5 100644 --- a/docs/src/pages/system-reference/modules.md +++ b/docs/src/pages/system-reference/modules.md @@ -7,7 +7,7 @@ sidebarTitle: Modules ## Module vs Route Activation -Modularity has two activation concepts: +Modularous has two activation concepts: 1. **Module enable/disable**: Via Nwidart's activator (e.g. `modules_statuses.json` or database). Controls whether a module is loaded at all. @@ -15,6 +15,8 @@ Modularity has two activation concepts: A module can be enabled but have specific routes disabled (e.g. hide the create route). +See [Backend · Activators](/system-reference/backend/activators/overview) for class-level details of `ModularityActivator` and `ModuleActivator`. + ## Module Discovery Modules are scanned from: @@ -57,4 +59,4 @@ Standard route actions (Module::$routeActionLists): restore, forceDelete, duplic Use `php artisan modularity:route:enable` and `modularity:route:disable` to toggle routes. Status is stored in `modules/{ModuleName}/routes_statuses.json`. -See [route:enable](/guide/commands/route-enable) and [route:disable](/guide/commands/route-disable). +See [route:enable](/guide/console/module/route-enable) and [route:disable](/guide/console/module/route-disable). diff --git a/docs/src/pages/system-reference/index.md b/docs/src/pages/system-reference/overview.md similarity index 89% rename from docs/src/pages/system-reference/index.md rename to docs/src/pages/system-reference/overview.md index c27d94d54..6fd32098b 100644 --- a/docs/src/pages/system-reference/index.md +++ b/docs/src/pages/system-reference/overview.md @@ -14,8 +14,8 @@ Modularity (Modularous) is a Laravel package that provides a modular admin panel | [Architecture](./architecture) | System overview, request flow, schema flow, core classes | | [Hydrates](./hydrates) | Backend → frontend schema transformation (input types) | | [Repositories](./repositories) | Data access layer, lifecycle, Logic traits | -| [Backend](./backend) | Controllers, Console commands, Entities, Services | -| [Frontend](./frontend) | Vue structure, form/table flow, hooks, store | +| [Backend](./backend/overview) | Controllers, Console commands, Entities, Services | +| [Frontend](./frontend/overview) | Vue structure, form/table flow, hooks, store | | [Config](./config) | Configuration layers (merges, defers, publishes) | | [Modules](./modules) | Module vs route activation, structure | | [API](./api) | Common patterns and use cases | @@ -45,4 +45,4 @@ Modularity (Modularous) is a Laravel package that provides a modular admin panel ## For Contributors -See [AGENTS.md](https://github.com/unusualify/modularity/blob/main/AGENTS.md) for package development rules, patterns, and conventions. +See [AGENTS.md](https://github.com/unusualify/modularous/blob/main/AGENTS.md) for package development rules, patterns, and conventions. diff --git a/docs/src/pages/system-reference/pinia-migration.md b/docs/src/pages/system-reference/pinia-migration.md index 49f6588af..c678e6566 100644 --- a/docs/src/pages/system-reference/pinia-migration.md +++ b/docs/src/pages/system-reference/pinia-migration.md @@ -5,7 +5,7 @@ sidebarTitle: Pinia Migration # Pinia Migration Path -Modularity currently uses Vuex 4. For new projects, Pinia is the recommended state management library for Vue 3. +Modularous currently uses Vuex 4. For new projects, Pinia is the recommended state management library for Vue 3. ## Current State @@ -45,4 +45,4 @@ const { isInertia } = storeToRefs(configStore) ## Target Version -Pinia migration is planned for Modularity v4.x. No timeline set. +Pinia migration is planned for Modularous v4.x. No timeline set. diff --git a/lang/en.json b/lang/en.json index a6a87720a..c85ccad6b 100755 --- a/lang/en.json +++ b/lang/en.json @@ -127,5 +127,30 @@ "You are receiving this email because we received a password reset request for your account.": "You are receiving this email because we received a password reset request for your account.", "You can easily monitor the entire process by following the notifications.": "You can easily monitor the entire process by following the notifications.", "Your Request Has Been Sent Successfully": "Your Request Has Been Sent Successfully", + "messages": { + "success": "Completed Successfully!", + "info": "Completed!", + "warning": "There is a warning!", + "error": "There is an error!", + "promotion_disabled_hint": "CMS promotion is turned off. Set MODULARITY_CMS_PROMOTION_ENABLED=true in the environment and clear config cache.", + "promotion_scope": "Scope", + "promotion_dry_run": "Dry-run (preview)", + "promotion_execute": "Execute", + "promotion_last_result": "Last response", + "promotion_confirm_title": "Run promotion execute?", + "promotion_confirm_body": "This will flush Modularity cache when not in dry-run mode. Data is not copied between environments.", + "promotion_intro": "Preview counts for the current database (dry-run). Execute flushes the Modularity cache; it does not sync data between servers.", + "site_seo_db_disabled": "Database-backed site SEO is disabled (MODULARITY_CMS_SEO_ROBOTS_USE_SITE_SETTINGS=false). The editor shows the env fallback; saving is ignored until this is enabled.", + "site_seo_robots_title": "Global robots.txt", + "site_seo_robots_label": "Body", + "site_seo_intro": "This text is served at GET /robots.txt (when the route is enabled). Clearing the field and saving reverts to the environment default.", + "step_up_verification_required": "Verification required", + "step_up_description_default": "Please enter the verification code sent to your email.", + "step_up_verify": "Verify", + "step_up_resend_code": "Resend code", + "step_up_otp_label": "Verification code", + "step_up_resend_success": "A new verification code has been sent.", + "step_up_resend_failed": "The verification code could not be resent." + }, "YY": "YY" - } +} diff --git a/lang/en/fields.php b/lang/en/fields.php index 512f53cbe..fb913aacf 100755 --- a/lang/en/fields.php +++ b/lang/en/fields.php @@ -48,6 +48,11 @@ 'show' => 'Show', 'step' => 'Step', 'sign-in' => 'Sign In', + 'signed_preview_copy_link' => 'Copy shareable preview link', + 'signed_preview_expires_note' => 'Link expires in {n} minutes.', + 'signed_preview_copy_prompt' => 'Copy preview URL (then Ctrl/Cmd+C):', + 'signed_preview_copy_failed' => 'Could not copy the link automatically.', + 'signed_preview_copied' => 'Preview link copied to clipboard.', 'submit' => 'Submit', 'success-create' => 'Created Successfully.', 'success-delete' => 'Removed Successfully.', diff --git a/lang/en/messages.php b/lang/en/messages.php index d2aefc975..79a205fa9 100755 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -1,6 +1,9 @@ <?php return [ + 'cancel' => 'Cancel', + 'confirm' => 'Confirm', + 'assignment' => [ 'task-to-assignee-by-assigner' => 'to {assigneeName} — by {assignerName}', ], @@ -10,6 +13,84 @@ 'invalid-company' => 'Company fields must be filled!', 'password-saved' => 'Password saved successfully!', 'profile-update-success' => 'Your profile was successfully updated!', + + 'revision' => [ + 'source-date-tooltip' => 'This is the date of the revision that was restored from (snapshot before this version).', + 'restore-disabled-already-restored' => 'This version was already created by restoring an older revision; it cannot be restored again.', + 'preview-tab' => 'Preview', + 'diff-tab' => 'Diff', + 'diff-compare-with' => 'Compare with (older)', + 'diff-no-older' => 'There is no older revision to compare this snapshot against.', + 'diff-load-error' => 'Could not load revision data for diff.', + 'diff-from-to' => 'Changes from older snapshot to the one you opened', + 'compare-tab' => 'Compare', + 'compare-older' => 'Older (left)', + 'compare-newer' => 'Newer (right)', + 'compare-load-error' => 'Could not load one or both HTML previews for compare.', + 'compare-hint' => 'Two live previews side by side — same baseline as text diff.', + 'preview-sidebar-title' => 'Versions', + 'preview-sidebar-current' => 'You are viewing this version', + 'pending-locks-record' => 'This record has a revision pending approval. Save and restore are disabled until it is resolved.', + 'restore-blocked-pending' => 'Cannot restore a revision while another revision is pending approval.', + 'restore-blocked-rejected' => 'A rejected revision cannot be restored.', + 'restore-forbidden' => 'You are not allowed to restore revisions for this module.', + 'approve-not-applicable' => 'Revision approval workflow is not enabled for this model.', + 'approve-not-pending' => 'Only a pending revision can be approved.', + 'approve-not-current-pending' => 'The selected revision is not the current pending revision.', + 'approve-not-latest' => 'Only the latest revision can be pending or approved; refresh and try again.', + 'pending-only-one' => 'A pending revision already exists (only the latest revision may be pending).', + 'approved-success' => 'The pending revision was approved and applied.', + 'approve-action' => 'Approve', + 'approve-failed' => 'Could not approve the revision.', + 'reject-not-applicable' => 'Revision rejection is not enabled for this model.', + 'reject-not-latest' => 'Only the latest revision can be rejected.', + 'reject-not-pending' => 'Only a pending revision can be rejected.', + 'rejected-success' => 'The pending revision was rejected. Live content was not changed.', + 'reject-failed' => 'Could not reject the revision.', + 'reject-action' => 'Reject', + 'approve-confirm-title' => 'Approve this revision?', + 'approve-confirm-body' => 'The pending changes will be applied to the live record. This action cannot be undone from here.', + 'restore-confirm-title' => 'Restore this revision?', + 'restore-confirm-body' => 'The record will be updated to match this snapshot. A new revision entry may be created depending on your workflow.', + 'reject-confirm-title' => 'Reject this revision?', + 'reject-confirm-body' => 'The pending proposal will be discarded. Published content will stay as it is.', + 'status-pending' => 'Pending approval', + 'status-rejected' => 'Rejected', + ], + + 'bulk' => [ + 'headline' => 'Import / export', + 'intro' => 'Upload a CSV file. Dry-run validates only; commit writes in a single transaction.', + 'columns' => 'Expected columns', + 'column' => 'Column', + 'csv_headers' => 'CSV headers', + 'required' => 'Required', + 'csv_file' => 'CSV file', + 'choose_csv' => 'Choose CSV', + 'dry_run' => 'Dry-run (preview)', + 'dry_run_ok' => 'Dry-run OK', + 'dry_run_failed' => 'Dry-run failed', + 'import_completed' => 'Import completed', + 'import_failed' => 'Import failed', + 'request_failed' => 'Request failed', + 'commit' => 'Commit import', + 'export' => 'Download export (CSV)', + 'result' => 'Result', + 'created' => 'Created', + 'updated' => 'Updated', + 'valid_rows' => 'Valid rows', + 'confirm_title' => 'Commit import?', + 'confirm_body' => 'All rows must pass validation. The import runs in a single database transaction.', + 'preview_line' => 'Line', + 'preview_ok' => 'OK', + 'preview_action' => 'Action', + 'preview_locale' => 'Locale', + 'preview_from' => 'From', + 'preview_to' => 'To', + 'preview_errors' => 'Errors', + 'preview_warnings' => 'Warnings', + ], + 'notifications' => [ 'mark-read-success' => 'Notifications marked as read.', ], diff --git a/lang/en/modules.php b/lang/en/modules.php index 72ce54b65..d3eeb064d 100755 --- a/lang/en/modules.php +++ b/lang/en/modules.php @@ -1,6 +1,27 @@ <?php return [ + 'cms' => [ + 'page' => [ + 'name' => 'Page | Pages | {n} Page', + ], + 'redirect' => [ + 'name' => 'Redirect | Redirects | {n} Redirect', + + 'intro' => 'Required: locale, from path, to path. Optional: status code (301–308), active (0/1).', + 'headline' => 'Redirect import / export', + 'browser_title' => 'Redirect import / export', + ], + 'parent_segment' => [ + 'name' => 'Parent Segment | Parent Segments | {n} Parent Segment', + ], + 'sitemap' => [ + 'name' => 'Sitemap | Sitemap | {n} Sitemap', + ], + 'site_setting' => [ + 'name' => 'Site Setting | Site Settings | {n} Site Setting', + ], + ], 'system_notification' => [ 'my_notification' => [ 'name' => 'My Notification | My Notifications | {n} My Notification', @@ -59,6 +80,12 @@ 'user' => [ 'name' => 'User | Users | {n} Users', ], + 'capability' => [ + 'name' => 'Capability | Capabilities | {n} Capabilities', + ], + 'capability_route' => [ + 'name' => 'Capability Route | Capability Routes | {n} Capability Routes', + ], ], 'system_utility' => [ 'country' => [ diff --git a/lang/en/validation.php b/lang/en/validation.php index 9589fe00d..c88585ccc 100755 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -13,153 +13,156 @@ | */ - 'accepted' => 'The :attribute field must be accepted.', - 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', - 'after' => 'The :attribute field must be a date after :date.', - 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', - 'active_url' => 'The :attribute field must be a valid URL.', - 'alpha' => 'The :attribute field must only contain letters.', - 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', - 'alpha_num' => 'The :attribute field must only contain letters and numbers.', - 'array' => 'The :attribute field must be an array.', - 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', - 'before' => 'The :attribute field must be a date before :date.', - 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'accepted' => 'The {attribute} field must be accepted.', + 'accepted_if' => 'The {attribute} field must be accepted when {other} is {value}.', + 'after' => 'The {attribute} field must be a date after {date}.', + 'after_or_equal' => 'The {attribute} field must be a date after or equal to {date}.', + 'active_url' => 'The {attribute} field must be a valid URL.', + 'alpha' => 'The {attribute} field must only contain letters.', + 'alpha_dash' => 'The {attribute} field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The {attribute} field must only contain letters and numbers.', + 'array' => 'The {attribute} field must be an array.', + 'ascii' => 'The {attribute} field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The {attribute} field must be a date before {date}.', + 'before_or_equal' => 'The {attribute} field must be a date before or equal to {date}.', 'between' => [ - 'array' => 'The :attribute field must have between :min and :max items.', - 'file' => 'The :attribute field must be between :min and :max kilobytes.', - 'numeric' => 'The :attribute field must be between :min and :max.', - 'string' => 'The :attribute field must be between :min and :max characters.', + 'array' => 'The {attribute} field must have between {min} and {max} items.', + 'file' => 'The {attribute} field must be between {min} and {max} kilobytes.', + 'numeric' => 'The {attribute} field must be between {min} and {max}.', + 'string' => 'The {attribute} field must be between {min} and {max} characters.', ], - 'boolean' => 'The :attribute field must be true or false.', - 'can' => 'The :attribute field contains an unauthorized value.', - 'confirmed' => 'The :attribute field confirmation does not match.', + 'boolean' => 'The {attribute} field must be true or false.', + 'can' => 'The {attribute} field contains an unauthorized value.', + 'confirmed' => 'The {attribute} field confirmation does not match.', 'current_password' => 'The password is incorrect.', - 'date' => 'The :attribute field must be a valid date.', - 'date_equals' => 'The :attribute field must be a date equal to :date.', - 'date_format' => 'The :attribute field must match the format :format.', + 'date' => 'The {attribute} field must be a valid date.', + 'date_equals' => 'The {attribute} field must be a date equal to {date}.', + 'date_format' => 'The {attribute} field must match the format {format}.', 'date_must_be_future' => 'Date must be at least {interval} in the future', 'date_must_be_now' => 'Date must be at least {interval}', 'date_must_be_past' => 'Date must be at least {interval} in the past', - 'decimal' => 'The :attribute field must have :decimal decimal places.', - 'declined' => 'The :attribute field must be declined.', - 'declined_if' => 'The :attribute field must be declined when :other is :value.', - 'different' => 'The :attribute field and :other must be different.', - 'digits' => 'The :attribute field must be :digits digits.', - 'digits_between' => 'The :attribute field must be between :min and :max digits.', - 'dimensions' => 'The :attribute field has invalid image dimensions.', - 'distinct' => 'The :attribute field has a duplicate value.', - 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', - 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', - 'email' => 'The :attribute field must be a valid email address.', - 'email_contains_invalid_characters' => 'The :attribute field contains invalid characters.', - 'email_username_min_length' => 'The :attribute field must be at least :minLength characters.', - 'email_max_length' => 'The :attribute field must be less than :maxLength characters.', - 'email_domain_must_be_one_of' => 'The :attribute field must be one of the following domains: :allowedDomains.', - 'email_domain_not_allowed' => 'The :attribute field domain is not allowed.', - 'email_typo_suggestion' => 'Did you mean :suggestion?', - 'ends_with' => 'The :attribute field must end with one of the following: :values.', - 'enum' => 'The selected :attribute is invalid.', - 'exists' => 'The selected :attribute is invalid.', - 'file' => 'The :attribute field must be a file.', - 'filled' => 'The :attribute field must have a value.', + 'decimal' => 'The {attribute} field must have {decimal} decimal places.', + 'declined' => 'The {attribute} field must be declined.', + 'declined_if' => 'The {attribute} field must be declined when {other} is {value}.', + 'different' => 'The {attribute} field and {other} must be different.', + 'digits' => 'The {attribute} field must be {digits} digits.', + 'digits_between' => 'The {attribute} field must be between {min} and {max} digits.', + 'dimensions' => 'The {attribute} field has invalid image dimensions.', + 'distinct' => 'The {attribute} field has a duplicate value.', + 'doesnt_end_with' => 'The {attribute} field must not end with one of the following: {values}.', + 'doesnt_start_with' => 'The {attribute} field must not start with one of the following: {values}.', + 'email' => 'The {attribute} field must be a valid email address.', + 'email_contains_invalid_characters' => 'The {attribute} field contains invalid characters.', + 'email_username_min_length' => 'The {attribute} field must be at least {minLength} characters.', + 'email_max_length' => 'The {attribute} field must be less than {maxLength} characters.', + 'email_domain_must_be_one_of' => 'The {attribute} field must be one of the following domains: {allowedDomains}.', + 'email_domain_not_allowed' => 'The {attribute} field domain is not allowed.', + 'email_typo_suggestion' => 'Did you mean {suggestion}?', + 'ends_with' => 'The {attribute} field must end with one of the following: {values}.', + 'enum' => 'The selected {attribute} is invalid.', + 'exists' => 'The selected {attribute} is invalid.', + 'file' => 'The {attribute} field must be a file.', + 'filled' => 'The {attribute} field must have a value.', 'gt' => [ - 'array' => 'The :attribute field must have more than :value items.', - 'file' => 'The :attribute field must be greater than :value kilobytes.', - 'numeric' => 'The :attribute field must be greater than :value.', - 'string' => 'The :attribute field must be greater than :value characters.', + 'array' => 'The {attribute} field must have more than {value} items.', + 'file' => 'The {attribute} field must be greater than {value} kilobytes.', + 'numeric' => 'The {attribute} field must be greater than {value}.', + 'string' => 'The {attribute} field must be greater than {value} characters.', ], 'gte' => [ - 'array' => 'The :attribute field must have :value items or more.', - 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', - 'numeric' => 'The :attribute field must be greater than or equal to :value.', - 'string' => 'The :attribute field must be greater than or equal to :value characters.', + 'array' => 'The {attribute} field must have {value} items or more.', + 'file' => 'The {attribute} field must be greater than or equal to {value} kilobytes.', + 'numeric' => 'The {attribute} field must be greater than or equal to {value}.', + 'string' => 'The {attribute} field must be greater than or equal to {value} characters.', ], - 'image' => 'The :attribute field must be an image.', - 'in' => 'The selected :attribute is invalid.', - 'in_array' => 'The :attribute field must exist in :other.', - 'integer' => 'The :attribute field must be an integer.', - 'ip' => 'The :attribute field must be a valid IP address.', - 'ipv4' => 'The :attribute field must be a valid IPv4 address.', - 'ipv6' => 'The :attribute field must be a valid IPv6 address.', - 'json' => 'The :attribute field must be a valid JSON string.', - 'lowercase' => 'The :attribute field must be lowercase.', + 'image' => 'The {attribute} field must be an image.', + 'in' => 'The selected {attribute} is invalid.', + 'in_array' => 'The {attribute} field must exist in {other}.', + 'integer' => 'The {attribute} field must be an integer.', + 'ip' => 'The {attribute} field must be a valid IP address.', + 'ipv4' => 'The {attribute} field must be a valid IPv4 address.', + 'ipv6' => 'The {attribute} field must be a valid IPv6 address.', + 'json' => 'The {attribute} field must be a valid JSON string.', + 'lowercase' => 'The {attribute} field must be lowercase.', 'lt' => [ - 'array' => 'The :attribute field must have less than :value items.', - 'file' => 'The :attribute field must be less than :value kilobytes.', - 'numeric' => 'The :attribute field must be less than :value.', - 'string' => 'The :attribute field must be less than :value characters.', + 'array' => 'The {attribute} field must have less than {value} items.', + 'file' => 'The {attribute} field must be less than {value} kilobytes.', + 'numeric' => 'The {attribute} field must be less than {value}.', + 'string' => 'The {attribute} field must be less than {value} characters.', ], 'lte' => [ - 'array' => 'The :attribute field must not have more than :value items.', - 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', - 'numeric' => 'The :attribute field must be less than or equal to :value.', - 'string' => 'The :attribute field must be less than or equal to :value characters.', + 'array' => 'The {attribute} field must not have more than {value} items.', + 'file' => 'The {attribute} field must be less than or equal to {value} kilobytes.', + 'numeric' => 'The {attribute} field must be less than or equal to {value}.', + 'string' => 'The {attribute} field must be less than or equal to {value} characters.', ], - 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'mac_address' => 'The {attribute} field must be a valid MAC address.', 'max' => [ - 'array' => 'The :attribute field must not have more than :max items.', - 'file' => 'The :attribute field must not be greater than :max kilobytes.', - 'numeric' => 'The :attribute field must not be greater than :max.', - 'string' => 'The :attribute field must not be greater than :max characters.', + 'array' => 'The {attribute} field must not have more than {max} items.', + 'file' => 'The {attribute} field must not be greater than {max} kilobytes.', + 'numeric' => 'The {attribute} field must not be greater than {max}.', + 'string' => 'The {attribute} field must not be greater than {max} characters.', ], - 'max_digits' => 'The :attribute field must not have more than :max digits.', - 'mimes' => 'The :attribute field must be a file of type: :values.', - 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'max_digits' => 'The {attribute} field must not have more than {max} digits.', + 'mimes' => 'The {attribute} field must be a file of type: {values}.', + 'mimetypes' => 'The {attribute} field must be a file of type: {values}.', 'min' => [ - 'array' => 'The :attribute field must have at least :min items.', - 'file' => 'The :attribute field must be at least :min kilobytes.', - 'numeric' => 'The :attribute field must be at least :min.', - 'string' => 'The :attribute field must be at least :min characters.', + 'array' => 'The {attribute} field must have at least {min} items.', + 'file' => 'The {attribute} field must be at least {min} kilobytes.', + 'numeric' => 'The {attribute} field must be at least {min}.', + 'string' => 'The {attribute} field must be at least {min} characters.', ], - 'min_digits' => 'The :attribute field must have at least :min digits.', - 'missing' => 'The :attribute field must be missing.', - 'missing_if' => 'The :attribute field must be missing when :other is :value.', - 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', - 'missing_with' => 'The :attribute field must be missing when :values is present.', - 'missing_with_all' => 'The :attribute field must be missing when :values are present.', - 'multiple_of' => 'The :attribute field must be a multiple of :value.', - 'not_in' => 'The selected :attribute is invalid.', - 'not_regex' => 'The :attribute field format is invalid.', - 'numeric' => 'The :attribute field must be a number.', + 'min_digits' => 'The {attribute} field must have at least {min} digits.', + 'missing' => 'The {attribute} field must be missing.', + 'missing_if' => 'The {attribute} field must be missing when {other} is {value}.', + 'missing_unless' => 'The {attribute} field must be missing unless {other} is {value}.', + 'missing_with' => 'The {attribute} field must be missing when {values} is present.', + 'missing_with_all' => 'The {attribute} field must be missing when {values} are present.', + 'multiple_of' => 'The {attribute} field must be a multiple of {value}.', + 'not_in' => 'The selected {attribute} is invalid.', + 'not_regex' => 'The {attribute} field format is invalid.', + 'numeric' => 'The {attribute} field must be a number.', 'password' => [ - 'letters' => 'The :attribute field must contain at least one letter.', - 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', - 'numbers' => 'The :attribute field must contain at least one number.', - 'symbols' => 'The :attribute field must contain at least one symbol.', - 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + 'letters' => 'The {attribute} field must contain at least one letter.', + 'mixed' => 'The {attribute} field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The {attribute} field must contain at least one number.', + 'symbols' => 'The {attribute} field must contain at least one symbol.', + 'uncompromised' => 'The given {attribute} has appeared in a data leak. Please choose a different {attribute}.', ], - 'present' => 'The :attribute field must be present.', - 'prohibited' => 'The :attribute field is prohibited.', - 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', - 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', - 'prohibits' => 'The :attribute field prohibits :other from being present.', - 'regex' => 'The :attribute field format is invalid.', - 'required' => 'The :attribute field is required.', - 'required_array_keys' => 'The :attribute field must contain entries for: :values.', - 'required_if' => 'The :attribute field is required when :other is :value.', - 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', - 'required_unless' => 'The :attribute field is required unless :other is in :values.', - 'required_with' => 'The :attribute field is required when :values is present.', - 'required_with_all' => 'The :attribute field is required when :values are present.', - 'required_without' => 'The :attribute field is required when :values is not present.', - 'required_without_all' => 'The :attribute field is required when none of :values are present.', - 'same' => 'The :attribute field must match :other.', + 'present' => 'The {attribute} field must be present.', + 'prohibited' => 'The {attribute} field is prohibited.', + 'prohibited_if' => 'The {attribute} field is prohibited when {other} is {value}.', + 'prohibited_unless' => 'The {attribute} field is prohibited unless {other} is in {values}.', + 'prohibits' => 'The {attribute} field prohibits {other} from being present.', + 'regex' => 'The {attribute} field format is invalid.', + 'required' => 'The {attribute} field is required.', + 'required_array_keys' => 'The {attribute} field must contain entries for: {values}.', + 'required_at_least' => 'Requires at least no item | Requires at least one item | Requires at least {count} items', + 'required_exact' => 'Requires exactly no item | Requires exactly one item | Requires exactly {count} items', + 'required_if' => 'The {attribute} field is required when {other} is {value}.', + 'required_if_accepted' => 'The {attribute} field is required when {other} is accepted.', + 'required_maximum' => 'and maximum of: {count}', + 'required_unless' => 'The {attribute} field is required unless {other} is in {values}.', + 'required_with' => 'The {attribute} field is required when {values} is present.', + 'required_with_all' => 'The {attribute} field is required when {values} are present.', + 'required_without' => 'The {attribute} field is required when {values} is not present.', + 'required_without_all' => 'The {attribute} field is required when none of {values} are present.', + 'same' => 'The {attribute} field must match {other}.', 'size' => [ - 'array' => 'The :attribute field must contain :size items.', - 'file' => 'The :attribute field must be :size kilobytes.', - 'numeric' => 'The :attribute field must be :size.', - 'string' => 'The :attribute field must be :size characters.', + 'array' => 'The {attribute} field must contain {size} items.', + 'file' => 'The {attribute} field must be {size} kilobytes.', + 'numeric' => 'The {attribute} field must be {size}.', + 'string' => 'The {attribute} field must be {size} characters.', ], - 'starts_with' => 'The :attribute field must start with one of the following: :values.', - 'string' => 'The :attribute field must be a string.', - 'timezone' => 'The :attribute field must be a valid timezone.', - 'ulid' => 'The :attribute field must be a valid ULID.', - 'unique' => 'The :attribute has already been taken.', - 'uploaded' => 'The :attribute failed to upload.', - 'uppercase' => 'The :attribute field must be uppercase.', - 'url' => 'The :attribute field must be a valid URL.', - 'uuid' => 'The :attribute field must be a valid UUID.', + 'starts_with' => 'The {attribute} field must start with one of the following: {values}.', + 'string' => 'The {attribute} field must be a string.', + 'timezone' => 'The {attribute} field must be a valid timezone.', + 'ulid' => 'The {attribute} field must be a valid ULID.', + 'unique' => 'The {attribute} field has already been taken.', + 'uploaded' => 'The {attribute} field failed to upload.', + 'uppercase' => 'The {attribute} field must be uppercase.', + 'url' => 'The {attribute} field must be a valid URL.', + 'uuid' => 'The {attribute} field must be a valid UUID.', /* |-------------------------------------------------------------------------- diff --git a/lang/tr.json b/lang/tr.json index 352b10c0a..4db566bf0 100755 --- a/lang/tr.json +++ b/lang/tr.json @@ -126,5 +126,30 @@ "You are receiving this email because we need to verify your email address.": "E-posta adresinizi doğrulamak için bu maili alıyorsunuz.", "You are receiving this email because we received a password reset request for your account.": "Hesabınızın şifresini sıfırlamak için bu maili alıyorsunuz", "Your Request Has Been Sent Successfully": "Başvurunuz Başarıyla Gönderildi", + "messages": { + "success": "Başarıyla Tamamlandı!", + "info": "Tamamlandı!", + "warning": "Bir Uyarı Var!", + "error": "Bir Hata Oluştu!", + "promotion_disabled_hint": "CMS promotion kapalı. Ortamda MODULARITY_CMS_PROMOTION_ENABLED=true yapın ve config önbelleğini temizleyin.", + "promotion_scope": "Kapsam", + "promotion_dry_run": "Önizleme (dry-run)", + "promotion_execute": "Çalıştır", + "promotion_last_result": "Son yanıt", + "promotion_confirm_title": "Promotion execute çalıştırılsın mı?", + "promotion_confirm_body": "Dry-run dışında Modularity önbelleği temizlenir. Ortamlar arası veri kopyalanmaz.", + "promotion_intro": "Geçerli veritabanı için sayımları önizleyin (dry-run). Execute Modularity önbelleğini temizler; sunucular arası veri senkronu yapmaz.", + "site_seo_db_disabled": "Site SEO veritabanı devre dışı (MODULARITY_CMS_SEO_ROBOTS_USE_SITE_SETTINGS=false). Düzenleyici ortam varsayılanını gösterir; bu açılana kadar kayıt yok sayılır.", + "site_seo_robots_title": "Genel robots.txt", + "site_seo_robots_label": "İçerik", + "site_seo_intro": "Bu metin (rota açıksa) GET /robots.txt adresinde sunulur. Alanı temizleyip kaydederseniz ortam varsayılanına dönersiniz.", + "step_up_verification_required": "Doğrulama gerekli", + "step_up_description_default": "E-postanıza gönderilen doğrulama kodunu girin.", + "step_up_verify": "Doğrula", + "step_up_resend_code": "Kodu yeniden gönder", + "step_up_otp_label": "Doğrulama kodu", + "step_up_resend_success": "Yeni bir doğrulama kodu gönderildi.", + "step_up_resend_failed": "Doğrulama kodu yeniden gönderilemedi." + }, "YY": "YY" } diff --git a/lang/tr/messages.php b/lang/tr/messages.php index 110b88da9..7a8611e66 100755 --- a/lang/tr/messages.php +++ b/lang/tr/messages.php @@ -1,6 +1,42 @@ <?php return [ + 'cancel' => 'İptal', + 'confirm' => 'Onayla', + + 'revision' => [ + 'source-date-tooltip' => 'Bu tarih, geri yüklemenin yapıldığı önceki revizyonun tarihidir (bu sürümden önceki anlık görüntü).', + 'restore-blocked-pending' => 'Başka bir revizyon onay beklerken eski bir sürüme geri dönülemez.', + 'restore-blocked-rejected' => 'Reddedilmiş bir revizyondan geri yükleme yapılamaz.', + 'restore-disabled-already-restored' => 'Bu sürüm zaten eski bir revizyondan geri yükleme ile oluşturuldu; tekrar geri yüklenemez.', + 'preview-tab' => 'Önizleme', + 'diff-tab' => 'Fark', + 'diff-compare-with' => 'Karşılaştır (daha eski)', + 'diff-no-older' => 'Bu anlık görüntüyü karşılaştırmak için daha eski bir revizyon yok.', + 'diff-load-error' => 'Fark için revizyon verileri yüklenemedi.', + 'diff-from-to' => 'Eski anlık görüntüden açtığınız sürüme yapılan değişiklikler', + 'compare-tab' => 'Karşılaştır', + 'compare-older' => 'Eski (sol)', + 'compare-newer' => 'Yeni (sağ)', + 'compare-load-error' => 'Karşılaştırma için HTML önizlemeleri yüklenemedi.', + 'compare-hint' => 'İki canlı önizleme yan yana — metin farkı ile aynı baz.', + 'preview-sidebar-title' => 'Sürümler', + 'preview-sidebar-current' => 'Şu an bu sürümü görüntülüyorsunuz', + 'status-pending' => 'Onay bekliyor', + 'status-rejected' => 'Reddedildi', + 'approve-confirm-title' => 'Bu revizyonu onaylamak istiyor musunuz?', + 'approve-confirm-body' => 'Bekleyen değişiklikler canlı kayda uygulanacaktır. Bu işlem buradan geri alınamaz.', + 'restore-confirm-title' => 'Bu revizyona geri dönmek istiyor musunuz?', + 'restore-confirm-body' => 'Kayıt bu anlık görüntüye göre güncellenecektir. İş akışınıza bağlı olarak yeni bir revizyon satırı oluşabilir.', + 'reject-not-applicable' => 'Bu model için revizyon reddi etkin değil.', + 'reject-not-latest' => 'Yalnızca en son revizyon reddedilebilir.', + 'reject-not-pending' => 'Yalnızca onay bekleyen bir revizyon reddedilebilir.', + 'rejected-success' => 'Bekleyen revizyon reddedildi. Yayınlanan içerik değiştirilmedi.', + 'reject-failed' => 'Revizyon reddedilemedi.', + 'reject-action' => 'Reddet', + 'reject-confirm-title' => 'Bu revizyonu reddetmek istiyor musunuz?', + 'reject-confirm-body' => 'Bekleyen öneri iptal edilecektir. Yayında olan içerik aynı kalacaktır.', + ], 'assignment' => [ 'task-to-assignee-by-assigner' => 'İlgili kullanıcı: {assigneeName} — Oluşturan: {assignerName}', ], @@ -10,6 +46,46 @@ 'invalid-company' => 'Şirket detayları doldurulmak zorundadır!', 'password-saved' => 'Şifre başarıyla kaydedildi!', 'profile-update-success' => 'Profiliniz başarıyla güncellendi!', + 'bulk' => [ + 'headline' => 'İçe / dışa aktar', + 'intro' => 'Bir CSV yükleyin. Önizleme yalnızca doğrular; kayıt tek veritabanı işleminde yazılır.', + 'columns' => 'Beklenen sütunlar', + 'column' => 'Sütun', + 'csv_headers' => 'CSV başlıkları', + 'required' => 'Zorunlu', + 'csv_file' => 'CSV dosyası', + 'choose_csv' => 'CSV seç', + 'dry_run' => 'Önizleme (dry-run)', + 'dry_run_ok' => 'Önizleme tamam', + 'dry_run_failed' => 'Önizleme başarısız', + 'import_completed' => 'İçe aktarma tamamlandı', + 'import_failed' => 'İçe aktarma başarısız', + 'request_failed' => 'İstek başarısız', + 'commit' => 'İçe aktarımı uygula', + 'export' => 'Dışa aktar (CSV indir)', + 'result' => 'Sonuç', + 'created' => 'Oluşturulan', + 'updated' => 'Güncellenen', + 'valid_rows' => 'Geçerli satır', + 'confirm_title' => 'İçe aktarılsın mı?', + 'confirm_body' => 'Tüm satırlar doğrulamadan geçmelidir. İçe aktarma tek bir veritabanı işleminde çalışır.', + 'preview_line' => 'Satır', + 'preview_ok' => 'Tamam', + 'preview_action' => 'İşlem', + 'preview_locale' => 'Dil', + 'preview_from' => 'Kaynak', + 'preview_to' => 'Hedef', + 'preview_errors' => 'Hatalar', + 'preview_warnings' => 'Uyarılar', + 'cms' => [ + 'redirect' => [ + 'intro' => 'Zorunlu: dil, kaynak yol, hedef yol. İsteğe bağlı: durum kodu (301–308), etkin (0/1).', + 'headline' => 'Yönlendirme içe / dışa aktarma', + 'browser_title' => 'Yönlendirme içe / dışa aktarma', + ], + ], + ], + 'notifications' => [ 'mark-read-success' => 'Bildirimler okundu olarak işaretlendi.', ], diff --git a/modules/.gitkeep b/modules/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Config/.gitkeep b/modules/Cms/Config/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Config/config.php b/modules/Cms/Config/config.php new file mode 100644 index 000000000..df52f3caa --- /dev/null +++ b/modules/Cms/Config/config.php @@ -0,0 +1,287 @@ +<?php + +return [ + 'name' => 'Cms', + 'system_prefix' => true, + 'group' => 'system', + 'headline' => 'CMS', + 'url' => 'cms', + + 'promotion' => [ + 'enabled' => modularityConfig('cms_promotion.enabled', true), + 'scope' => modularityConfig('cms_promotion.scope', []), + 'approval' => modularityConfig('cms_promotion.approval', []), + ], + + 'routes' => [ + 'site_setting' => [ + 'name' => 'SiteSetting', + 'headline' => 'Site Settings', + 'url' => 'site-settings', + 'route_name' => 'site_setting', + 'icon' => 'mdi-cog-sync-outline', + 'title_column_key' => 'key', + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + ], + 'headers' => [ + ['title' => 'Group', 'key' => 'group_key', 'searchable' => true], + ['title' => 'Key', 'key' => 'key', 'searchable' => true], + ['title' => 'Locale', 'key' => 'locale', 'searchable' => true], + ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], + ], + 'inputs' => [ + ['name' => 'group_key', 'label' => 'Group', 'type' => 'text', 'rules' => 'required'], + ['name' => 'key', 'label' => 'Key', 'type' => 'text', 'rules' => 'required'], + ['name' => 'locale', 'label' => 'Locale', 'type' => 'locale', 'rules' => 'required'], + ['name' => 'value', 'label' => 'Value', 'type' => 'textarea'], + ], + ], + + 'page' => [ + 'name' => 'Page', + 'headline' => 'Pages', + 'url' => 'pages', + 'route_name' => 'page', + 'icon' => '$submodule', + 'title_column_key' => 'title', + 'table_options' => [ + 'includeScheduledInList' => true, + 'editOnModal' => false, + ], + 'headers' => [ + ['title' => 'Title', 'key' => 'title', 'searchable' => true], + ['title' => 'Published', 'key' => 'published', 'formatter' => [ + 0 => 'switch', + 1 => [ + 'trueValue' => true, + 'falseValue' => false, + ], + ]], + ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], + ], + 'inputs' => [ + // ['type' => 'switch', 'name' => 'published', 'label' => 'Published', 'trueValue' => true, 'falseValue' => false, 'isEvent' => true], + // ['name' => 'publish_start_date', 'label' => 'Publish from', 'type' => 'date', 'isSecondary' => true], + // ['name' => 'publish_end_date', 'label' => 'Publish until', 'type' => 'date', 'isSecondary' => true], + ['type' => 'switch', 'name' => 'active', 'label' => 'Active', 'translated' => true, 'trueValue' => true, 'falseValue' => false, 'isSecondary' => true], + ['type' => 'revision', 'maxHeight' => '150px'], + ['type' => 'text', 'name' => 'title', 'label' => 'Title', 'translated' => true, 'rules' => 'required', 'ext' => 'update:slugs:slugSourceValue:modelValue'], + ['type' => 'slug', 'name' => 'slugs', 'label' => 'URL slug', 'translated' => true, 'rules' => 'required', '_moduleName' => 'Cms', '_routeName' => 'page', 'localeScoped' => true], + + ['type' => 'file', 'name' => 'documents', 'label' => 'Files', 'translated' => true], + ['type' => 'image', 'name' => 'photos', 'label' => 'Images', 'translated' => true], + [ + 'type' => 'filepond', + 'name' => 'attachments', + 'label' => 'Fileponds', + 'translated' => true, + 'acceptedExtensions' => ['jpeg', 'jpg', 'png', 'gif', 'bmp', 'tiff', 'ico', 'webp'], + 'allowImagePreview' => true, + ], + [ + 'type' => 'json-repeater', + 'name' => 'sessions', + 'label' => 'Sessions', + 'translated' => false, + 'asObject' => true, + 'default' => [], + 'noHeaders' => true, + 'formRowAttribute' => [ + 'noGutters' => true, + 'class' => 'mt-6', + ], + 'schema' => [ + [ + 'type' => 'text', + 'name' => 'session_title', + 'label' => 'Session Title', 'type' => 'text', + 'col' => [ + 'cols' => 6, + 'class' => 'pr-2', + ], + ], + [ + 'type' => 'textarea', + 'name' => 'session_description', + 'label' => 'Session Description', + 'col' => [ + 'cols' => 6, + ], + ], + ], + ], + ['name' => 'layout', 'label' => 'Layout', 'type' => 'text'], + ['name' => 'content', 'label' => 'Content', 'type' => 'textarea', 'translated' => true], + ['name' => 'schema', 'label' => 'Schema', 'type' => 'json', 'isSecondary' => true], + ], + ], + 'redirect' => [ + 'name' => 'Redirect', + 'headline' => 'Redirects', + 'url' => 'redirects', + 'route_name' => 'redirect', + 'icon' => 'mdi-directions-fork', + 'title_column_key' => 'from_path', + 'headers' => [ + ['title' => 'Locale', 'key' => 'locale', 'searchable' => true], + ['title' => 'From', 'key' => 'from_path', 'searchable' => true], + ['title' => 'To', 'key' => 'to_path', 'searchable' => true], + ['title' => 'Status', 'key' => 'status_code'], + ['title' => 'Active', 'key' => 'is_active', 'formatter' => [ + 0 => 'switch', + 1 => [ + 'trueValue' => true, + 'falseValue' => false, + ], + ]], + ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], + ], + 'inputs' => [ + ['name' => 'from_path', 'label' => 'From path', 'type' => 'text', 'rules' => 'required'], + ['name' => 'to_path', 'label' => 'To path', 'type' => 'text', 'rules' => 'required'], + ['name' => 'locale', 'label' => 'Locale', 'type' => 'select', 'items' => getLocales(), 'rules' => 'required'], + ['name' => 'status_code', 'label' => 'Status Code', 'type' => 'number', 'rules' => 'required|integer|in:301,302,307,308'], + ['name' => 'is_active', 'label' => 'Active', 'type' => 'switch'], + ], + + // CSV bulk sheet: default tool_key is derived from module + route (e.g. cms.redirect); override with tool_key if needed. + 'bulk_sheet' => [ + 'export_download_filename' => 'redirects-export.csv', + 'step_up_ability' => 'redirect.bulk_import', + 'preview_table_columns' => [ + ['title' => 'Line', 'key' => 'line', 'width' => '72px'], + ['title' => 'OK', 'key' => 'valid', 'sortable' => false], + ['title' => 'Action', 'key' => 'action'], + ['title' => 'Locale', 'key' => 'locale'], + ['title' => 'From', 'key' => 'from_path'], + ['title' => 'To', 'key' => 'to_path'], + ['title' => 'Errors', 'key' => 'errors', 'sortable' => false], + ['title' => 'Warnings', 'key' => 'warnings', 'sortable' => false], + ], + 'api_route_names' => [ + 'dryRun' => 'bulk.dryRun', + 'commit' => 'bulk.commit', + 'export' => 'bulk.export', + ], + ], + ], + + 'parent_segment' => [ + 'name' => 'ParentSegment', + 'headline' => 'URL parent segments', + 'url' => 'parent-segments', + 'route_name' => 'parent_segment', + 'icon' => 'mdi-source-branch', + 'title_column_key' => 'target_model_class', + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + ], + 'headers' => [ + ['title' => 'Model', 'key' => 'target_model_class', 'searchable' => true], + ['title' => 'Locale', 'key' => 'locale', 'searchable' => true], + ['title' => 'Prefix', 'key' => 'normalized_prefix', 'searchable' => true], + ['title' => 'Label', 'key' => 'admin_label', 'searchable' => true], + ['title' => 'Enabled', 'key' => 'enabled', 'formatter' => [ + 0 => 'switch', + 1 => [ + 'trueValue' => true, + 'falseValue' => false, + ], + ]], + ['title' => 'Sort', 'key' => 'sort_order'], + ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], + ], + 'inputs' => [ + [ + 'type' => 'module-route-model', + 'name' => 'target_model_class', + 'label' => 'Module / route (model)', + 'rules' => 'required|string|max:512', + 'onlyParentSegmentModels' => true, + ], + ['name' => 'locale', 'label' => 'Locale (empty = all locales)', 'type' => 'text', 'rules' => 'nullable|string|max:12'], + ['name' => 'normalized_prefix', 'label' => 'URL path prefix (empty = homepage / locale root)', 'type' => 'text', 'rules' => 'nullable|string|max:2048'], + ['name' => 'admin_label', 'label' => 'Admin label', 'type' => 'text', 'rules' => 'nullable|string|max:255'], + ['name' => 'enabled', 'label' => 'Enabled', 'type' => 'switch', 'trueValue' => true, 'falseValue' => false], + ['name' => 'sort_order', 'label' => 'Sort order', 'type' => 'number'], + ], + ], + + /** + * Panel Inertia index ({@code Cms/Sitemap/Index}): item table + dry-run + commit; {@see \Modules\Cms\Repositories\SitemapRepository}, + * {@see \Modules\Cms\Http\Controllers\SitemapController}, {@see \Modules\Cms\Http\Controllers\CmsSitemapPanelController}, {@see \Modules\Cms\Routes\web}. + */ + 'sitemap' => [ + 'name' => 'Sitemap', + 'headline' => 'Sitemap', + 'url' => 'sitemap', + 'route_name' => 'sitemap', + 'icon' => 'mdi-sitemap', + 'title_column_key' => 'id', + 'headers' => [ + ['title' => 'ID', 'key' => 'id', 'searchable' => true], + ['title' => 'Slug', 'key' => 'slug', 'searchable' => true], + ['title' => 'Created At', 'key' => 'created_at', 'searchable' => true], + ['title' => 'Updated At', 'key' => 'updated_at', 'searchable' => true], + ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], + ], + 'inputs' => [], + ], + 'homepage_test' => [ + 'name' => 'HomepageTest', + 'headline' => 'Homepage Tests', + 'url' => 'homepage-tests', + 'route_name' => 'homepage_test', + 'icon' => '$submodule', + 'title_column_key' => 'name', + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + 'isRowEditing' => false, + 'rowActionsType' => 'inline', + ], + 'headers' => [ + [ + 'title' => 'Name', + 'key' => 'name', + 'formatter' => [ + 'edit', + ], + 'searchable' => true, + ], + [ + 'title' => 'Status', + 'key' => 'published', + 'formatter' => [ + 'switch', + ], + ], + [ + 'title' => 'Created Time', + 'key' => 'created_at', + 'formatter' => [ + 'date', + 'long', + ], + 'searchable' => true, + ], + [ + 'title' => 'Actions', + 'key' => 'actions', + 'sortable' => false, + ], + ], + 'inputs' => [ + [ + 'type' => 'text', + 'name' => 'name', + 'label' => 'Name', + 'translated' => true, + ] + ], + ], + ], +]; diff --git a/modules/Cms/Console/RebuildCmsSitemapCommand.php b/modules/Cms/Console/RebuildCmsSitemapCommand.php new file mode 100644 index 000000000..a8de34c9f --- /dev/null +++ b/modules/Cms/Console/RebuildCmsSitemapCommand.php @@ -0,0 +1,30 @@ +<?php + +namespace Modules\Cms\Console; + +use Illuminate\Console\Command; +use Modules\Cms\Services\CmsSitemapBuildService; +use Modules\Cms\Services\CmsSitemapCacheService; + +class RebuildCmsSitemapCommand extends Command +{ + protected $signature = 'cms:sitemap:rebuild {--dry-run : Print XML to stdout only; do not update cache}'; + + protected $description = 'Build CMS sitemap from UrlRoute (page_public) and write to the committed cache (unless --dry-run).'; + + public function handle(CmsSitemapBuildService $build, CmsSitemapCacheService $cache): int + { + $xml = $build->buildXml(); + + if ((bool) $this->option('dry-run')) { + $this->line($xml); + + return self::SUCCESS; + } + + $cache->commit($xml); + $this->info('Sitemap built and cache committed.'); + + return self::SUCCESS; + } +} diff --git a/modules/Cms/Contracts/CanonicalUrlResolverInterface.php b/modules/Cms/Contracts/CanonicalUrlResolverInterface.php new file mode 100644 index 000000000..3e05eae76 --- /dev/null +++ b/modules/Cms/Contracts/CanonicalUrlResolverInterface.php @@ -0,0 +1,17 @@ +<?php + +namespace Modules\Cms\Contracts; + +interface CanonicalUrlResolverInterface +{ + public function resolve(?string $host, string $path, ?string $locale = null, array $options = []): array; + + public function normalizePath(string $path): string; + + /** + * Values to match against {@see \Modules\Cms\Entities\UrlRoute::normalized_path} (legacy rows may omit a leading slash). + * + * @return list<string> + */ + public function normalizedPathRegistryLookupVariants(string $pathKey): array; +} diff --git a/modules/Cms/Contracts/CmsLocalizationContract.php b/modules/Cms/Contracts/CmsLocalizationContract.php new file mode 100644 index 000000000..6cdd9a31e --- /dev/null +++ b/modules/Cms/Contracts/CmsLocalizationContract.php @@ -0,0 +1,53 @@ +<?php + +namespace Modules\Cms\Contracts; + +/** + * Bridges CMS URL features ({@see \Modules\Cms\Entities\UrlRoute}, slugs, {@see \Modules\Cms\Services\CmsPublicModelResolver}) + * with a concrete localization stack (typically {@code mcamara/laravel-localization}) or translatable fallbacks. + * + * Implementations are resolved from the container as a singleton; higher layers (e.g. SiteSetting) can decorate via + * {@see CmsLocalizationOverrideProviderInterface}. + */ +interface CmsLocalizationContract +{ + /** + * Identifier for diagnostics: {@code mcamara}, {@code translatable}, etc. + */ + public function driver(): string; + + /** + * Locale codes allowed as the first path segment after {@see \Modules\Cms\Support\CmsFrontPath::innerNormalizedPath()}. + * Sorted longest-first (e.g. {@code en-gb} before {@code en}). + * + * @return list<string> + */ + public function pathSegmentLocales(): array; + + /** + * Rich locale list for admin / language switcher UIs (native names, script, regional). + * + * @return array<string, array<string, mixed>> + */ + public function supportedLocalesMeta(): array; + + public function defaultLocale(): string; + + /** + * When true, default locale URLs omit the leading {@code /{locale}} segment (aligned with mcamara's + * {@code hideDefaultLocaleInURL} when that driver is active). + */ + public function hideDefaultLocaleInUrl(): bool; + + /** + * Build a localized URL for a path or absolute URL (wraps mcamara {@code getLocalizedURL} when available). + */ + public function localizeUrl(?string $url = null, ?string $locale = null): string; + + /** + * Strip locale prefix from a URL/path (wraps mcamara {@code getNonLocalizedURL} when available). + */ + public function stripLocaleFromUrl(?string $url = null): string; + + public function applyLocaleToApplication(string $locale): void; +} diff --git a/modules/Cms/Contracts/CmsLocalizationOverrideProviderInterface.php b/modules/Cms/Contracts/CmsLocalizationOverrideProviderInterface.php new file mode 100644 index 000000000..8d6f95f2e --- /dev/null +++ b/modules/Cms/Contracts/CmsLocalizationOverrideProviderInterface.php @@ -0,0 +1,24 @@ +<?php + +namespace Modules\Cms\Contracts; + +/** + * Optional overrides for {@see CmsLocalizationContract} (future: SiteSetting / DB-backed config). + * Returning {@code null} from a method means "use the inner adapter's value". + */ +interface CmsLocalizationOverrideProviderInterface +{ + /** + * @return list<string>|null + */ + public function pathSegmentLocales(): ?array; + + public function defaultLocale(): ?string; + + public function hideDefaultLocaleInUrl(): ?bool; + + /** + * @return array<string, array<string, mixed>>|null + */ + public function supportedLocalesMeta(): ?array; +} diff --git a/modules/Cms/Contracts/CmsPromotionScopeApplierInterface.php b/modules/Cms/Contracts/CmsPromotionScopeApplierInterface.php new file mode 100644 index 000000000..78f7b13bd --- /dev/null +++ b/modules/Cms/Contracts/CmsPromotionScopeApplierInterface.php @@ -0,0 +1,17 @@ +<?php + +namespace Modules\Cms\Contracts; + +/** + * Runs post-promotion work for enabled scope flags (cache is handled in {@see \Modules\Cms\Services\CmsPromotionService}). + * Swap or extend the binding to plug in search reindex, webhooks, or cross-service sync. + */ +interface CmsPromotionScopeApplierInterface +{ + /** + * @param array<string, mixed> $scope Resolved scope flags + * @param array<string, mixed> $context `dry_run`, `user`, `diff` snapshot + * @return array{applied: list<string>, skipped: list<string>} + */ + public function applyAfterPromotion(array $scope, array $context): array; +} diff --git a/modules/Cms/Contracts/CmsSearchDriverInterface.php b/modules/Cms/Contracts/CmsSearchDriverInterface.php new file mode 100644 index 000000000..608e0ce76 --- /dev/null +++ b/modules/Cms/Contracts/CmsSearchDriverInterface.php @@ -0,0 +1,12 @@ +<?php + +namespace Modules\Cms\Contracts; + +interface CmsSearchDriverInterface +{ + public function index(string $entityType, int|string $entityId, array $document): void; + + public function remove(string $entityType, int|string $entityId): void; + + public function search(string $query, array $options = []): array; +} diff --git a/modules/Cms/Contracts/LeadDeliveryInterface.php b/modules/Cms/Contracts/LeadDeliveryInterface.php new file mode 100644 index 000000000..e4ad0a8d1 --- /dev/null +++ b/modules/Cms/Contracts/LeadDeliveryInterface.php @@ -0,0 +1,8 @@ +<?php + +namespace Modules\Cms\Contracts; + +interface LeadDeliveryInterface +{ + public function deliver(array $leadPayload): array; +} diff --git a/modules/Cms/Contracts/PublicUrlRegistryContract.php b/modules/Cms/Contracts/PublicUrlRegistryContract.php new file mode 100644 index 000000000..9988a3c2a --- /dev/null +++ b/modules/Cms/Contracts/PublicUrlRegistryContract.php @@ -0,0 +1,36 @@ +<?php + +namespace Modules\Cms\Contracts; + +/** + * CMS public URL registry: locale + normalized path rows owned by morph urlables. + * Implemented by {@see \Modules\Cms\Services\CmsUrlRouteRegistry}; bind in {@see \Modules\Cms\Providers\CmsServiceProvider}. + */ +interface PublicUrlRegistryContract +{ + public function tableReady(): bool; + + /** + * Rebuild PAGE_PUBLIC UrlRoute rows for every model instance of {@code $modelClass} + * (HasSlug / IsSingular targets — see registry implementation). + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + */ + public function syncPublicPageRoutesForAllModelsOfClass(string $modelClass): void; + + /** + * Whether another row already claims this locale + path. + * + * @param class-string<\Illuminate\Database\Eloquent\Model>|null $excludeUrlableType Morph class of the entity being edited (exclude that pair) + * @param int|null $excludeUrlableId Primary key paired with {@see $excludeUrlableType} + */ + public function isPathClaimedByOther(string $locale, string $normalizedPath, ?string $excludeUrlableType = null, ?int $excludeUrlableId = null): bool; + + /** + * Non-blocking hints when two public paths share a prefix/child relationship (same route kind + owner morph). + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $pathOwnerMorphClass + * @return list<string> + */ + public function nestedPathPrefixWarnings(string $locale, string $normalizedPath, string $routeKind, string $pathOwnerMorphClass, ?int $exceptUrlableId = null): array; +} diff --git a/modules/Cms/Contracts/RedirectValidationServiceInterface.php b/modules/Cms/Contracts/RedirectValidationServiceInterface.php new file mode 100644 index 000000000..00ac555fd --- /dev/null +++ b/modules/Cms/Contracts/RedirectValidationServiceInterface.php @@ -0,0 +1,8 @@ +<?php + +namespace Modules\Cms\Contracts; + +interface RedirectValidationServiceInterface +{ + public function validate(string $fromPath, string $toPath, array $options = []): array; +} diff --git a/modules/Cms/Database/Migrations/.gitkeep b/modules/Cms/Database/Migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Database/Migrations/2026_03_26_000001_create_cms_foundation_tables.php b/modules/Cms/Database/Migrations/2026_03_26_000001_create_cms_foundation_tables.php new file mode 100644 index 000000000..d7d4b76a9 --- /dev/null +++ b/modules/Cms/Database/Migrations/2026_03_26_000001_create_cms_foundation_tables.php @@ -0,0 +1,176 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + $pagesTable = modularityConfig('tables.cms_pages', 'um_cms_pages'); + $pageTranslationsTable = modularityConfig('tables.cms_page_translations', 'um_cms_page_translations'); + $pageSlugsTable = modularityConfig('tables.cms_page_slugs', 'um_cms_page_slugs'); + $pageRevisionsTable = modularityConfig('tables.cms_pages_revisions', 'um_cms_pages_revisions'); + $redirectsTable = modularityConfig('tables.cms_redirects', 'um_cms_redirects'); + $siteSettingsTable = modularityConfig('tables.cms_site_settings', 'um_cms_site_settings'); + $searchIndexesTable = modularityConfig('tables.cms_search_indexes', 'um_cms_search_indexes'); + $urlRoutesTable = modularityConfig('tables.cms_url_routes', 'um_cms_url_routes'); + $sitemapsTable = modularityConfig('tables.cms_sitemaps', 'um_cms_sitemaps'); + $sitemapablesTable = modularityConfig('tables.cms_sitemapables', 'um_cms_sitemapables'); + $parentSegmentBindingsTable = modularityConfig('tables.cms_parent_segment_bindings', 'um_cms_parent_segment_bindings'); + + // Legacy two-table layout (replaced by bindings); safe if already rolled back / empty. + $oldParentSegmentTargetsTable = modularityConfig('tables.cms_parent_segment_targets', 'um_cms_parent_segment_targets'); + $oldParentSegmentsTable = modularityConfig('tables.cms_parent_segments', 'um_cms_parent_segments'); + Schema::dropIfExists($oldParentSegmentTargetsTable); + Schema::dropIfExists($oldParentSegmentsTable); + + Schema::create($pagesTable, function (Blueprint $table) { + $table->id(); + $table->string('layout')->nullable(); + $table->json('schema')->nullable(); + + $table->string('approval_state')->default('draft'); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->timestamp('approved_at')->nullable(); + + createDefaultExtraTableFields($table, published: true, publishDates: true, visibility: false); + + $table->index(['published', 'approval_state']); + }); + + Schema::create($pageTranslationsTable, function (Blueprint $table) use ($pagesTable) { + createDefaultTranslationsTableFields($table, 'Page', $pagesTable); + $table->string('title')->nullable(); + $table->string('slug_segment')->nullable(); + $table->text('excerpt')->nullable(); + $table->longText('content')->nullable(); + createTranslatableMetadataFields($table); + }); + + Schema::create($pageSlugsTable, function (Blueprint $table) use ($pagesTable) { + createDefaultSlugsTableFields($table, 'page', $pagesTable); + }); + + Schema::create($pageRevisionsTable, function (Blueprint $table) use ($pagesTable) { + createDefaultRevisionsTableFields($table, 'page', $pagesTable); + }); + + Schema::create($redirectsTable, function (Blueprint $table) { + $table->id(); + $table->string('from_path'); + $table->string('to_path'); + $table->string('locale', 12)->index(); + $table->unsignedSmallInteger('status_code')->default(301); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['from_path', 'locale']); + }); + + Schema::create($siteSettingsTable, function (Blueprint $table) { + $table->id(); + $table->string('group_key'); + $table->string('key'); + $table->string('locale', 12); + $table->longText('value')->nullable(); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['group_key', 'key', 'locale']); + }); + + Schema::create($searchIndexesTable, function (Blueprint $table) { + $table->id(); + $table->string('entity_type'); + $table->string('entity_id'); + $table->longText('document'); + $table->timestamps(); + + $table->unique(['entity_type', 'entity_id']); + }); + + Schema::create($urlRoutesTable, function (Blueprint $table) { + $table->id(); + $table->string('locale', 12)->index(); + $table->string('normalized_path', 2048); + $table->morphs('urlable'); + $table->string('kind', 32)->nullable()->index(); + $table->timestamps(); + + $table->unique(['locale', 'normalized_path']); + }); + + Schema::create($sitemapsTable, function (Blueprint $table): void { + $table->id(); + $table->string('slug')->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create($sitemapablesTable, function (Blueprint $table) use ($sitemapsTable): void { + $table->id(); + $table->foreignId('sitemap_id')->constrained($sitemapsTable)->cascadeOnDelete(); + $table->morphs('sitemapable'); + $table->string('changefreq', 32)->nullable(); + $table->decimal('priority', 2, 1)->nullable(); + $table->timestamps(); + + $table->unique( + ['sitemap_id', 'sitemapable_type', 'sitemapable_id'], + 'cms_sitemapables_sitemap_morph_unique' + ); + }); + + $now = now(); + DB::table($sitemapsTable)->insert([ + 'id' => 1, + 'slug' => 'default', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if (DB::getDriverName() === 'pgsql') { + $sequence = 'um_cms_sitemaps_id_seq'; + DB::statement("SELECT setval('{$sequence}', (SELECT MAX(id) FROM \"{$sitemapsTable}\"))"); + } + + Schema::dropIfExists($parentSegmentBindingsTable); + + Schema::create($parentSegmentBindingsTable, function (Blueprint $table): void { + $table->id(); + /** @var class-string<\Illuminate\Database\Eloquent\Model> */ + $table->string('target_model_class', 512); + /** Empty string = all locales */ + $table->string('locale', 12)->default(''); + $table->string('normalized_prefix', 2048); + $table->string('admin_label')->nullable(); + $table->boolean('enabled')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + // Short names: MySQL identifier limit is 64 chars. + $table->unique(['target_model_class', 'locale'], 'cms_psb_model_locale_unq'); + $table->index(['target_model_class', 'locale', 'enabled'], 'cms_psb_model_loc_en_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists(modularityConfig('tables.cms_parent_segment_bindings', 'um_cms_parent_segment_bindings')); + Schema::dropIfExists(modularityConfig('tables.cms_sitemapables', 'um_cms_sitemapables')); + Schema::dropIfExists(modularityConfig('tables.cms_sitemaps', 'um_cms_sitemaps')); + Schema::dropIfExists(modularityConfig('tables.cms_url_routes', 'um_cms_url_routes')); + Schema::dropIfExists(modularityConfig('tables.cms_pages_revisions', 'um_cms_pages_revisions')); + Schema::dropIfExists(modularityConfig('tables.cms_page_slugs', 'um_cms_page_slugs')); + Schema::dropIfExists(modularityConfig('tables.cms_search_indexes', 'um_cms_search_indexes')); + Schema::dropIfExists(modularityConfig('tables.cms_site_settings', 'um_cms_site_settings')); + Schema::dropIfExists(modularityConfig('tables.cms_redirects', 'um_cms_redirects')); + Schema::dropIfExists(modularityConfig('tables.cms_page_translations', 'um_cms_page_translations')); + Schema::dropIfExists(modularityConfig('tables.cms_pages', 'um_cms_pages')); + } +}; diff --git a/modules/Cms/Database/Seeders/.gitkeep b/modules/Cms/Database/Seeders/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Entities/.gitkeep b/modules/Cms/Entities/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Entities/CmsSitemapableItem.php b/modules/Cms/Entities/CmsSitemapableItem.php new file mode 100644 index 000000000..17285178b --- /dev/null +++ b/modules/Cms/Entities/CmsSitemapableItem.php @@ -0,0 +1,40 @@ +<?php + +namespace Modules\Cms\Entities; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; + +/** + * Override row: links a sitemap “bucket” to a public page (or other urlable) for {@code changefreq} / {@code priority}. + */ +class CmsSitemapableItem extends Model +{ + protected $fillable = [ + 'sitemap_id', + 'sitemapable_type', + 'sitemapable_id', + 'changefreq', + 'priority', + ]; + + protected $casts = [ + 'priority' => 'float', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_sitemapables', 'um_cms_sitemapables'); + } + + public function sitemap(): BelongsTo + { + return $this->belongsTo(Sitemap::class, 'sitemap_id'); + } + + public function sitemapable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/modules/Cms/Entities/Concerns/HasParentSegment.php b/modules/Cms/Entities/Concerns/HasParentSegment.php new file mode 100644 index 000000000..2414d9b04 --- /dev/null +++ b/modules/Cms/Entities/Concerns/HasParentSegment.php @@ -0,0 +1,27 @@ +<?php + +namespace Modules\Cms\Entities\Concerns; + +use Illuminate\Database\Eloquent\Collection; +use Modules\Cms\Entities\ParentSegment; + +/** + * Opt-in marker for Eloquent models that participate in URL parent-segment bindings + * (shared path prefixes per model class + locale). + * + * Used by {@see \Modules\Cms\Repositories\Traits\ParentSegmentTrait}, + * {@see \Unusualify\Modularity\Modularity::getModuleRouteModelSelectItems()}, + * and CMS slug validation when resolving public paths. + */ +trait HasParentSegment +{ + public static function supportsParentSegmentBindings(): bool + { + return true; + } + + public function parentSegments(): Collection + { + return ParentSegment::where('target_model_class', static::class)->get(); + } +} diff --git a/modules/Cms/Entities/Concerns/IsCmr.php b/modules/Cms/Entities/Concerns/IsCmr.php new file mode 100644 index 000000000..636520ce7 --- /dev/null +++ b/modules/Cms/Entities/Concerns/IsCmr.php @@ -0,0 +1,13 @@ +<?php + +namespace Modules\Cms\Entities\Concerns; + +/** + * Content module route (CMR): CMS panel routes that participate in parent-segment URL bindings and UrlRoute syncing. + * + * Composes {@see HasParentSegment}. Pair repositories with {@see \Modules\Cms\Repositories\Traits\CMRTrait}. + */ +trait IsCmr +{ + use HasParentSegment; +} diff --git a/modules/Cms/Entities/HomepageTest.php b/modules/Cms/Entities/HomepageTest.php new file mode 100644 index 000000000..bbbd6452f --- /dev/null +++ b/modules/Cms/Entities/HomepageTest.php @@ -0,0 +1,31 @@ +<?php + +namespace Modules\Cms\Entities; + +use Modules\Cms\Entities\Concerns\IsCmr; +use Unusualify\Modularity\Entities\Model; +use Unusualify\Modularity\Entities\Traits\HasFileponds; +use Unusualify\Modularity\Entities\Traits\HasTranslatableMetadata; +use Unusualify\Modularity\Entities\Traits\IsSingular; +use Unusualify\Modularity\Entities\Traits\Publishable; + +class HomepageTest extends Model +{ + use HasFileponds, + IsSingular, + IsCmr, + HasTranslatableMetadata, + Publishable; + + public bool $usePublishDates = true; + + /** + * The attributes that are mass assignable. + * + * @var array<int, string> + */ + protected $fillable = [ + 'name', + 'published', + ]; +} diff --git a/modules/Cms/Entities/Page.php b/modules/Cms/Entities/Page.php new file mode 100644 index 000000000..55e538c92 --- /dev/null +++ b/modules/Cms/Entities/Page.php @@ -0,0 +1,144 @@ +<?php + +namespace Modules\Cms\Entities; + +use Modules\Cms\Entities\Concerns\IsCmr; +use Modules\Cms\Entities\Revisions\PageRevision; +use Modules\Cms\Entities\Slugs\PageSlug; +use Unusualify\Modularity\Entities\Model; +use Unusualify\Modularity\Entities\Traits\HasFileponds; +use Unusualify\Modularity\Entities\Traits\HasFiles; +use Unusualify\Modularity\Entities\Traits\HasImages; +use Unusualify\Modularity\Entities\Traits\HasRepeaters; +use Unusualify\Modularity\Entities\Traits\HasRevisions; +use Unusualify\Modularity\Entities\Traits\HasSlug; +use Unusualify\Modularity\Entities\Traits\HasTranslatableMetadata; +use Unusualify\Modularity\Entities\Traits\HasTranslation; +use Unusualify\Modularity\Entities\Traits\Publishable; + +class Page extends Model +{ + use HasRevisions, + HasSlug, + HasTranslation, + HasTranslatableMetadata, + IsCmr, + HasFiles, + HasImages, + HasFileponds, + HasRepeaters, + Publishable; + + protected string $revisionModel = PageRevision::class; + + protected $revisionWorkflowEnabled = true; + + protected $isRevisionWorkflowEnabled = true; + + protected $slugModelClass = PageSlug::class; + + protected $slugForeignKey = 'page_id'; + + /** + * First entry is the source column used to derive URL slugs. If that column is also listed in + * {@see $translatedAttributes}, {@see HasSlug} reads each locale from translation rows; otherwise the value + * is taken from the owning model (slug_segment stays on pages while title/excerpt remain translated). + * + * @var list<string> + */ + protected $slugAttributes = [ + 'title', + ]; + + public $translatedAttributes = [ + 'active', + 'title', + // 'slug_segment', + 'excerpt', + 'content', + ]; + + protected $fillable = [ + 'layout', + 'schema', + 'published', + 'publish_start_date', + 'publish_end_date', + 'approval_state', + 'approved_by', + 'approved_at', + ]; + + protected $casts = [ + 'schema' => 'array', + 'published' => 'boolean', + 'approved_at' => 'datetime', + 'publish_start_date' => 'datetime', + 'publish_end_date' => 'datetime', + ]; + + // /** + // * Permissions: page_revision_approve, page_revision_restore (see modularity:sync:revision-permissions). + // */ + // protected function revisionPermissionPrefix(): ?string + // { + // return 'page'; + // } + + /** + * When true, editors without approve permission submit pending revisions only; subject row stays locked until approval. + */ + // protected function revisionWorkflowEnabled(): bool + // { + // return true; + // } + + /** + * Base {@see \Unusualify\Modularity\Entities\Model} maps null publish_start to "now"; CMS pages use null to clear the schedule. + */ + public function setPublishStartDateAttribute(mixed $value): void + { + if ($value === null || $value === '') { + $this->attributes['publish_start_date'] = null; + + return; + } + + $this->attributes['publish_start_date'] = $this->fromDateTime($value); + } + + /** + * Clear end date when empty (public visibility has no scheduled end). + */ + public function setPublishEndDateAttribute(mixed $value): void + { + if ($value === null || $value === '') { + $this->attributes['publish_end_date'] = null; + + return; + } + + $this->attributes['publish_end_date'] = $this->fromDateTime($value); + } + + public function getTable(): string + { + return modularityConfig('tables.cms_pages', 'um_cms_pages'); + } + + // /** + // * API / admin binding uses numeric id; front routes resolve by active locale slug via {@see existsSlug()}. + // */ + // public function resolveRouteBinding($value, $field = null) + // { + // if ($field === 'id' || $field === $this->getKeyName()) { + // return parent::resolveRouteBinding($value, $field); + // } + + // if ($field === null && ctype_digit((string) $value)) { + // return static::query()->where($this->getKeyName(), $value)->firstOrFail(); + // } + + // return $this->scopes(['published', 'visible'])->existsSlug($value)->firstOrFail(); + // } +} diff --git a/modules/Cms/Entities/ParentSegment.php b/modules/Cms/Entities/ParentSegment.php new file mode 100644 index 000000000..b905a8983 --- /dev/null +++ b/modules/Cms/Entities/ParentSegment.php @@ -0,0 +1,37 @@ +<?php + +namespace Modules\Cms\Entities; + +use Illuminate\Database\Eloquent\Model as EloquentModel; + +/** + * One URL prefix row per model class + locale (unique on target_model_class + locale). + * + * @property string $target_model_class + * @property string $locale Empty string = all locales + * @property string $normalized_prefix May be deliberately empty for locale-root URLs (homepage) when enabled. + * @property string|null $admin_label + * @property bool $enabled + * @property int $sort_order + */ +class ParentSegment extends EloquentModel +{ + protected $fillable = [ + 'target_model_class', + 'locale', + 'normalized_prefix', + 'admin_label', + 'enabled', + 'sort_order', + ]; + + protected $casts = [ + 'enabled' => 'boolean', + 'sort_order' => 'integer', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_parent_segment_bindings', 'um_cms_parent_segment_bindings'); + } +} diff --git a/modules/Cms/Entities/Redirect.php b/modules/Cms/Entities/Redirect.php new file mode 100644 index 000000000..52efa80af --- /dev/null +++ b/modules/Cms/Entities/Redirect.php @@ -0,0 +1,26 @@ +<?php + +namespace Modules\Cms\Entities; + +use Unusualify\Modularity\Entities\Model; + +class Redirect extends Model +{ + protected $fillable = [ + 'from_path', + 'to_path', + 'locale', + 'status_code', + 'is_active', + ]; + + protected $casts = [ + 'is_active' => 'boolean', + 'status_code' => 'integer', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_redirects', 'um_cms_redirects'); + } +} diff --git a/modules/Cms/Entities/Revisions/PageRevision.php b/modules/Cms/Entities/Revisions/PageRevision.php new file mode 100644 index 000000000..5e7098d8d --- /dev/null +++ b/modules/Cms/Entities/Revisions/PageRevision.php @@ -0,0 +1,29 @@ +<?php + +namespace Modules\Cms\Entities\Revisions; + +use Modules\Cms\Entities\Page; +use Unusualify\Modularity\Entities\Revision; + +class PageRevision extends Revision +{ + protected $fillable = [ + 'page_id', + 'payload', + 'user_id', + 'source_id', + 'status', + 'approved_at', + 'approved_by', + ]; + + public function page() + { + return $this->belongsTo(Page::class); + } + + public function getTable(): string + { + return modularityConfig('tables.cms_pages_revisions', 'um_cms_pages_revisions'); + } +} diff --git a/modules/Cms/Entities/SiteSetting.php b/modules/Cms/Entities/SiteSetting.php new file mode 100644 index 000000000..ba1998f86 --- /dev/null +++ b/modules/Cms/Entities/SiteSetting.php @@ -0,0 +1,25 @@ +<?php + +namespace Modules\Cms\Entities; + +use Unusualify\Modularity\Entities\Model; + +class SiteSetting extends Model +{ + protected $fillable = [ + 'group_key', + 'key', + 'locale', + 'value', + 'is_active', + ]; + + protected $casts = [ + 'is_active' => 'boolean', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_site_settings', 'um_cms_site_settings'); + } +} diff --git a/modules/Cms/Entities/Sitemap.php b/modules/Cms/Entities/Sitemap.php new file mode 100644 index 000000000..634738914 --- /dev/null +++ b/modules/Cms/Entities/Sitemap.php @@ -0,0 +1,26 @@ +<?php + +namespace Modules\Cms\Entities; + +use Illuminate\Database\Eloquent\Relations\HasMany; +use Unusualify\Modularity\Entities\Model; + +class Sitemap extends Model +{ + protected $fillable = [ + 'slug', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_sitemaps', 'um_cms_sitemaps'); + } + + /** + * Per-model XML overrides: {@code changefreq} / {@code priority} (nullable in DB → jeneratörde config default’ları). + */ + public function sitemapableItems(): HasMany + { + return $this->hasMany(CmsSitemapableItem::class, 'sitemap_id'); + } +} diff --git a/modules/Cms/Entities/Slugs/PageSlug.php b/modules/Cms/Entities/Slugs/PageSlug.php new file mode 100644 index 000000000..5f86a96ef --- /dev/null +++ b/modules/Cms/Entities/Slugs/PageSlug.php @@ -0,0 +1,27 @@ +<?php + +namespace Modules\Cms\Entities\Slugs; + +use Illuminate\Database\Eloquent\SoftDeletes; +use Unusualify\Modularity\Entities\Model; + +class PageSlug extends Model +{ + use SoftDeletes; + + protected $fillable = [ + 'page_id', + 'slug', + 'locale', + 'active', + ]; + + protected $casts = [ + 'active' => 'boolean', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_page_slugs', 'um_cms_page_slugs'); + } +} diff --git a/modules/Cms/Entities/Translations/PageTranslation.php b/modules/Cms/Entities/Translations/PageTranslation.php new file mode 100644 index 000000000..e12d4180a --- /dev/null +++ b/modules/Cms/Entities/Translations/PageTranslation.php @@ -0,0 +1,33 @@ +<?php + +namespace Modules\Cms\Entities\Translations; + +use Modules\Cms\Entities\Page; +use Unusualify\Modularity\Entities\Model; +use Unusualify\Modularity\Support\TranslatableMetadata; + +class PageTranslation extends Model +{ + public $baseModuleModel = Page::class; + + protected $fillable = [ + 'title', + 'slug_segment', + 'excerpt', + 'content', + 'active', + 'locale', + ...TranslatableMetadata::TRANSLATED_ATTRIBUTES, + ]; + + protected $casts = [ + // 'robots_index' => 'boolean', + // 'robots_follow' => 'boolean', + 'active' => 'boolean', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_page_translations', 'um_cms_page_translations'); + } +} diff --git a/modules/Cms/Entities/UrlRoute.php b/modules/Cms/Entities/UrlRoute.php new file mode 100644 index 000000000..8b4567051 --- /dev/null +++ b/modules/Cms/Entities/UrlRoute.php @@ -0,0 +1,42 @@ +<?php + +namespace Modules\Cms\Entities; + +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\Eloquent\Relations\MorphTo; + +/** + * Registry row: which entity owns a public path for a given locale (after normalization). + * Plain Eloquent (no package SoftDeletes) so the table stays a simple unique index. + * + * @property string $locale + * @property string $normalized_path + * @property string|null $kind + */ +class UrlRoute extends EloquentModel +{ + public const KIND_PAGE_PUBLIC = 'page_public'; + + public const KIND_REDIRECT_SOURCE = 'redirect_source'; + + protected $fillable = [ + 'locale', + 'normalized_path', + 'urlable_type', + 'urlable_id', + 'kind', + ]; + + public function getTable(): string + { + return modularityConfig('tables.cms_url_routes', 'um_cms_url_routes'); + } + + /** + * Owning model (e.g. Page, Redirect). + */ + public function urlable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/modules/Cms/Events/CmsPromotionExecuted.php b/modules/Cms/Events/CmsPromotionExecuted.php new file mode 100644 index 000000000..fb6f6d960 --- /dev/null +++ b/modules/Cms/Events/CmsPromotionExecuted.php @@ -0,0 +1,26 @@ +<?php + +namespace Modules\Cms\Events; + +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; + +/** + * Fired after a successful {@see \Modules\Cms\Services\CmsPromotionService::promote} when not dry-run. + * Listeners may trigger pipelines, notifications, or secondary cache invalidation. + */ +class CmsPromotionExecuted +{ + use Dispatchable, SerializesModels; + + /** + * @param array<string, mixed> $scope + * @param array<string, mixed> $report + */ + public function __construct( + public array $scope, + public array $report, + public ?Authenticatable $user = null, + ) {} +} diff --git a/modules/Cms/Events/CmsPublishWindowBoundaryReached.php b/modules/Cms/Events/CmsPublishWindowBoundaryReached.php new file mode 100644 index 000000000..46b4b7c16 --- /dev/null +++ b/modules/Cms/Events/CmsPublishWindowBoundaryReached.php @@ -0,0 +1,27 @@ +<?php + +namespace Modules\Cms\Events; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Events\Dispatchable; + +/** + * Fired when a scheduled scan detects that a row's {@code publish_start_date} or {@code publish_end_date} + * has just been crossed (within the configured look-back window). + * + * @see \Modules\Cms\Jobs\ScanCmsPublishWindowBoundariesJob + */ +final class CmsPublishWindowBoundaryReached +{ + use Dispatchable; + + /** + * @param class-string<Model> $modelClass + * @param 'publish_start'|'publish_end' $boundary + */ + public function __construct( + public string $modelClass, + public int|string $modelId, + public string $boundary, + ) {} +} diff --git a/modules/Cms/Http/Controllers/.gitkeep b/modules/Cms/Http/Controllers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Http/Controllers/Api/.gitkeep b/modules/Cms/Http/Controllers/Api/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Http/Controllers/Api/CmsRoutingMetaController.php b/modules/Cms/Http/Controllers/Api/CmsRoutingMetaController.php new file mode 100644 index 000000000..f2a088508 --- /dev/null +++ b/modules/Cms/Http/Controllers/Api/CmsRoutingMetaController.php @@ -0,0 +1,42 @@ +<?php + +namespace Modules\Cms\Http\Controllers\API; + +use Illuminate\Http\JsonResponse; +use Modules\Cms\Contracts\CmsLocalizationContract; +use Modules\Cms\Entities\Page; +use Modules\Cms\Services\CmsParentSegmentResolver; +use Unusualify\Modularity\Http\Controllers\Controller; + +/** + * Read-only CMS front routing metadata for admin UI (slug path preview, headers, tooling). + */ +class CmsRoutingMetaController extends Controller +{ + public function __invoke( + CmsParentSegmentResolver $parentSegmentResolver, + CmsLocalizationContract $localization, + ): JsonResponse { + return response()->json([ + 'front_route_prefix' => trim((string) modularityConfig('cms_routing.front_route_prefix', 'cms'), '/'), + 'localization_driver' => $localization->driver(), + 'default_locale' => $localization->defaultLocale(), + 'hide_default_locale_segment' => $localization->hideDefaultLocaleInUrl(), + 'locale_strategy' => (string) modularityConfig('cms_routing.locale_strategy', 'path'), + 'public_front_route_group_mode' => (string) modularityConfig('cms_routing.public_front_route_group_mode', 'catch_all'), + 'public_front_uses_locale_route_param' => \Modules\Cms\Routing\CmsFrontRouteLocalizationBinding::shouldUseLocalePrefixRouteGroup(), + 'path_segment_locales' => $localization->pathSegmentLocales(), + 'supported_locales' => $localization->supportedLocalesMeta(), + 'admin' => [ + 'slug_nested_path_warnings' => (bool) modularityConfig('cms_routing.admin.slug_nested_path_warnings', true), + 'slug_public_path_preview' => (bool) modularityConfig('cms_routing.admin.slug_public_path_preview', true), + 'slug_max_path_segments' => modularityConfig('cms_routing.admin.slug_max_path_segments'), + 'signed_preview_enabled' => (bool) modularityConfig('cms_routing.signed_preview.enabled', true), + 'signed_preview_ttl_minutes' => max(5, (int) modularityConfig('cms_routing.signed_preview.ttl_minutes', 60)), + ], + 'parent_segment_prefixes' => [ + Page::class => $parentSegmentResolver->normalizedPrefixesMapForTargetClass(Page::class), + ], + ]); + } +} diff --git a/modules/Cms/Http/Controllers/Api/HomepageTestController.php b/modules/Cms/Http/Controllers/Api/HomepageTestController.php new file mode 100644 index 000000000..261c29779 --- /dev/null +++ b/modules/Cms/Http/Controllers/Api/HomepageTestController.php @@ -0,0 +1,72 @@ +<?php + +namespace Modules\Cms\Http\Controllers\API; + +use Illuminate\Contracts\Support\Renderable; +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; +use Modules\Cms\Repositories\HomepageTestRepository; +use Modules\Cms\Transformers\HomepageTestResource; + +class HomepageTestController extends Controller +{ + /** + * This resource repository + */ + private $repository; + + public function __construct(HomepageTestRepository $repository) + { + $this->repository = $repository; + } + + /** + * Display a listing of the resource. + * + * @return Renderable + */ + public function index(Request $request) + { + return new HomepageTestResource($this->repository->paginate($request)); + } + + /** + * Store a newly created resource in storage. + * + * @return Renderable + */ + public function store(Request $request) + { + // + } + + /** + * Show the specified resource. + * + * @param int $id + * @return Renderable + */ + public function show($id) {} + + /** + * Update the specified resource in storage. + * + * @param int $id + * @return Renderable + */ + public function update(Request $request, $id) + { + // + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * @return Renderable + */ + public function destroy($id) + { + // + } +} diff --git a/modules/Cms/Http/Controllers/Api/ParentSegmentController.php b/modules/Cms/Http/Controllers/Api/ParentSegmentController.php new file mode 100644 index 000000000..6ca6d5a88 --- /dev/null +++ b/modules/Cms/Http/Controllers/Api/ParentSegmentController.php @@ -0,0 +1,68 @@ +<?php + +namespace Modules\Cms\Http\Controllers\API; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Modules\Cms\Entities\ParentSegment; +use Modules\Cms\Repositories\ParentSegmentRepository; +use Unusualify\Modularity\Http\Controllers\Controller; + +/** + * Optional JSON API for parent segment bindings (panel CRUD uses {@see \Modules\Cms\Http\Controllers\ParentSegmentController}). + */ +class ParentSegmentController extends Controller +{ + public function __construct( + protected ParentSegmentRepository $parentSegmentRepository + ) {} + + public function index(): JsonResponse + { + $rows = ParentSegment::query() + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + + return response()->json(['data' => $rows]); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'target_model_class' => 'required|string|max:512', + 'locale' => 'nullable|string|max:12', + 'normalized_prefix' => 'nullable|string|max:2048', + 'admin_label' => 'nullable|string|max:255', + 'enabled' => 'sometimes|boolean', + 'sort_order' => 'sometimes|integer|min:0', + ]); + + $segment = $this->parentSegmentRepository->create($validated); + + return response()->json(['data' => $segment->fresh()], 201); + } + + public function update(Request $request, ParentSegment $parent_segment): JsonResponse + { + $validated = $request->validate([ + 'target_model_class' => 'sometimes|string|max:512', + 'locale' => 'nullable|string|max:12', + 'normalized_prefix' => 'sometimes|string|max:2048', + 'admin_label' => 'nullable|string|max:255', + 'enabled' => 'sometimes|boolean', + 'sort_order' => 'sometimes|integer|min:0', + ]); + + $this->parentSegmentRepository->update($parent_segment->getKey(), $validated); + + return response()->json(['data' => $parent_segment->fresh()]); + } + + public function destroy(ParentSegment $parent_segment): JsonResponse + { + $this->parentSegmentRepository->delete($parent_segment->getKey()); + + return response()->json([], 204); + } +} diff --git a/modules/Cms/Http/Controllers/Api/PromotionController.php b/modules/Cms/Http/Controllers/Api/PromotionController.php new file mode 100644 index 000000000..e67affdee --- /dev/null +++ b/modules/Cms/Http/Controllers/Api/PromotionController.php @@ -0,0 +1,41 @@ +<?php + +namespace Modules\Cms\Http\Controllers\API; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Modules\Cms\Services\CmsPromotionService; +use Unusualify\Modularity\Http\Controllers\Controller; + +class PromotionController extends Controller +{ + public function __construct( + protected CmsPromotionService $promotionService, + ) { + parent::__construct(); + } + + public function dryRun(Request $request): JsonResponse + { + if (! modularityConfig('cms_promotion.enabled', false)) { + return response()->json(['ok' => false, 'message' => 'CMS promotion is disabled in configuration.'], 403); + } + + return response()->json($this->promotionService->promote([ + 'scope' => (array) $request->get('scope', []), + 'dry_run' => true, + ], $request->user())); + } + + public function execute(Request $request): JsonResponse + { + if (! modularityConfig('cms_promotion.enabled', false)) { + return response()->json(['ok' => false, 'message' => 'CMS promotion is disabled in configuration.'], 403); + } + + return response()->json($this->promotionService->promote([ + 'scope' => (array) $request->get('scope', []), + 'dry_run' => (bool) $request->boolean('dry_run', false), + ], $request->user())); + } +} diff --git a/modules/Cms/Http/Controllers/CmsSignedPublicPreviewController.php b/modules/Cms/Http/Controllers/CmsSignedPublicPreviewController.php new file mode 100644 index 000000000..c9256cf89 --- /dev/null +++ b/modules/Cms/Http/Controllers/CmsSignedPublicPreviewController.php @@ -0,0 +1,50 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Http\Request; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Http\Controllers\Front\CmsController; +use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Services\CmsSignedPreviewTargetResolver; +use Unusualify\Modularity\Http\Controllers\Controller; + +/** + * Public signed URL: resolves {@code module} + {@code route} + {@code id} to the correct {@see CmsController} presentation + * without publication scopes (any {@code HasParentSegment} submodule with a front controller). + */ +final class CmsSignedPublicPreviewController extends Controller +{ + public function __invoke( + Request $request, + CanonicalUrlResolverInterface $canonical, + CmsSignedPreviewTargetResolver $targetResolver, + CmsPublicModelResolver $publicModelResolver, + ) { + $moduleSeg = (string) $request->route('module'); + $routeSeg = (string) $request->route('route'); + $id = $request->route('id'); + $localeRaw = $request->route('locale'); + $locale = $localeRaw !== null && $localeRaw !== '' + ? (string) $localeRaw + : (string) modularityConfig('cms_routing.default_locale', config('app.locale')); + + $target = $targetResolver->resolve($moduleSeg, $routeSeg); + if ($target === null) { + abort(404); + } + + $repository = $target['module']->getRepository($target['routeKey'], true); + $modelClass = get_class($repository->getModel()); + + $item = $publicModelResolver->resolveByIdBypassingPublicationScopes($modelClass, $id, $locale); + if ($item === null) { + abort(404); + } + + /** @var CmsController $front */ + $front = app($target['frontControllerFqcn']); + + return $front->renderSignedPublicPreview($request, $canonical, $item); + } +} diff --git a/modules/Cms/Http/Controllers/CmsSitemapPanelController.php b/modules/Cms/Http/Controllers/CmsSitemapPanelController.php new file mode 100644 index 000000000..730784f1a --- /dev/null +++ b/modules/Cms/Http/Controllers/CmsSitemapPanelController.php @@ -0,0 +1,15 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Unusualify\Modularity\Http\Controllers\BaseController; + +/** + * Session-backed JSON for panel (dry-run / commit), aligned with {@see \Modules\Cms\Routes\web} pattern. + */ +class CmsSitemapPanelController extends BaseController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'Sitemap'; +} diff --git a/modules/Cms/Http/Controllers/Front/.gitkeep b/modules/Cms/Http/Controllers/Front/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Http/Controllers/Front/CmsController.php b/modules/Cms/Http/Controllers/Front/CmsController.php new file mode 100644 index 000000000..2936a72d0 --- /dev/null +++ b/modules/Cms/Http/Controllers/Front/CmsController.php @@ -0,0 +1,172 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Front; + +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Request; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Entities\UrlRoute; +use Modules\Cms\Http\Controllers\Traits\ResolvesPublicPresentationView; +use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Support\CmsPublicSeo; +use Unusualify\Modularity\Http\Controllers\CoreController; + +/** + * Public CMS front (non-Inertia) base controller: extends {@see CoreController} so {@see \Unusualify\Modularity\Traits\Moduleable} + * {@code $moduleName} / {@code $routeName} match admin controllers (e.g. {@see \Modules\Cms\Http\Controllers\PageController}). + * + * Presentation (aligned with {@see \Unusualify\Modularity\Http\Controllers\BaseController::getViewPrefix()} / + * {@see \Unusualify\Modularity\Http\Controllers\PanelController::$routePrefix} semantics for the submodule): + * - {@code $viewPrefix}: {@code snake(module)::snake(route)} (e.g. {@code cms::page}) + * - {@code $routePrefix}: {@code snake(module).snake(route)} (e.g. {@code cms.page}) + * + * Default Blade view for {@see __invoke}: {@code {$viewPrefix}.custom}. + * + * @todo Wire {@code HasCms} when available. + */ +abstract class CmsController extends CoreController +{ + use ResolvesPublicPresentationView; + + /** + * Blade view namespace fragment (e.g. {@code cms::page}), same pattern as admin {@see \Unusualify\Modularity\Http\Controllers\BaseController::$viewPrefix}. + */ + protected $viewPrefix; + + /** + * Dot-separated route-name prefix for this submodule (e.g. {@code cms.page}); mirrors admin {@see \Unusualify\Modularity\Http\Controllers\PanelController::$routePrefix} shape for public helpers. + */ + protected $routePrefix; + + public function __construct(Application $app, Request $request) + { + parent::__construct($app, $request); + $this->bootstrapCmsPublicPresentation(); + } + + /** + * Fills {@see $viewPrefix} and {@see $routePrefix} from {@see $moduleName} and {@see $routeName} (set on each concrete controller). + */ + protected function bootstrapCmsPublicPresentation(): void + { + $this->viewPrefix = $this->presentationViewPrefix(); + $this->routePrefix = $this->presentationRoutePrefix(); + } + + /** + * Resolves the published model: optional {@see modularityConfig('cms_routing.public_item_resolvers')} invokable override, + * otherwise {@see CmsPublicModelResolver} with {@see getPublicCmsEntityClass()} and {@see publicCmsUrlRouteKind()}. + */ + protected function resolvePublicItem(Request $request): ?Model + { + $key = $this->publicCmsModuleRouteKey(); + $handler = data_get((array) modularityConfig('cms_routing.public_item_resolvers', []), $key); + if (is_string($handler) && class_exists($handler)) { + return app($handler)($request); + } + + return app(CmsPublicModelResolver::class)->resolve( + $request, + $this->getPublicCmsEntityClass(), + $this->publicCmsUrlRouteKind() + ); + } + + /** + * Entity FQCN for this route (same as {@see CoreController::$repository} model when available). + * + * @return class-string<Model> + */ + protected function getPublicCmsEntityClass(): string + { + if ($this->repository !== null) { + return get_class($this->repository->getModel()); + } + + $name = (string) $this->getModelName(); + $class = "{$this->namespace}\\Entities\\{$name}"; + if (! class_exists($class)) { + throw new \LogicException("CMS front: cannot resolve entity class for [{$name}] in {$this->namespace}."); + } + + return $class; + } + + /** + * {@see UrlRoute::kind} for this public route (configurable per {@see publicCmsModuleRouteKey()}). + */ + protected function publicCmsUrlRouteKind(): string + { + $key = $this->publicCmsModuleRouteKey(); + + return (string) modularityConfig( + 'cms_routing.public_url_route_kind.' . $key, + UrlRoute::KIND_PAGE_PUBLIC + ); + } + + /** + * Key for resolver map / URL-route kind config, e.g. {@code Cms::Page}. + */ + protected function publicCmsModuleRouteKey(): string + { + return $this->getModuleName() . '::' . $this->getRouteName(); + } + + protected function publicCmsViewName(): string + { + return $this->presentationViewName(); + } + + public function __invoke( + Request $request, + CanonicalUrlResolverInterface $canonical, + ) { + $item = $this->resolvePublicItem($request); + + if ($item === null) { + abort(404); + } + + return $this->renderPublicCmsPresentation($request, $item, $canonical); + } + + /** + * Entry point for signed public preview URLs (delegated from {@see \Modules\Cms\Http\Controllers\CmsSignedPublicPreviewController}). + */ + public function renderSignedPublicPreview( + Request $request, + CanonicalUrlResolverInterface $canonical, + Model $item, + ): View { + return $this->renderPublicCmsPresentation($request, $item, $canonical, forcePreviewRobotsNoIndex: true); + } + + /** + * Shared Blade response for public catch-all routes and signed preview (non-Inertia). + */ + protected function renderPublicCmsPresentation( + Request $request, + Model $item, + CanonicalUrlResolverInterface $canonical, + bool $forcePreviewRobotsNoIndex = false, + ) { + $locale = app()->getLocale(); + $translation = method_exists($item, 'translate') ? $item->translate($locale) : null; + + $seo = CmsPublicSeo::build($request, $translation, $canonical); + if ($forcePreviewRobotsNoIndex) { + $seo['robotsMeta'] = 'noindex, nofollow'; + } + + return view($this->publicCmsViewName(), [ + 'item' => $item, + 'seoTitle' => $seo['title'], + 'seoDescription' => $seo['description'], + 'canonicalUrl' => $seo['canonicalUrl'], + 'robotsMeta' => $seo['robotsMeta'], + ]); + } +} diff --git a/modules/Cms/Http/Controllers/Front/CmsPublicFrontController.php b/modules/Cms/Http/Controllers/Front/CmsPublicFrontController.php new file mode 100644 index 000000000..2c5ba3fc9 --- /dev/null +++ b/modules/Cms/Http/Controllers/Front/CmsPublicFrontController.php @@ -0,0 +1,79 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Front; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Request; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Entities\UrlRoute; +use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Support\CmsPublicFrontViewName; +use Modules\Cms\Support\CmsPublicSeo; + +/** + * Single public catch-all invokable for the CMS module: resolves the entity from {@see \Modules\Cms\Entities\UrlRoute} + * for any model that is both on the {@see \Modules\Cms\Entities\ParentSegment} registry and uses + * {@see \Unusualify\Modularity\Entities\Traits\HasParentSegment} — no “first front controller in route order” + * ambiguity. See {@see \Modules\Cms\Routing\CmsFrontRouteRegistrar::resolveFrontControllerForModule()}. + */ +final class CmsPublicFrontController extends CmsController +{ + /** + * @var string + */ + protected $moduleName = 'Cms'; + + /** + * @var string + */ + protected $routeName = 'Public'; + + /** + * @see CmsController::resolvePublicItem() + */ + protected function resolvePublicItem(Request $request): ?Model + { + $key = $this->publicCmsModuleRouteKey(); + $handler = data_get((array) modularityConfig('cms_routing.public_item_resolvers', []), $key); + + if (is_string($handler) && class_exists($handler)) { + return app($handler)($request); + } + + $kind = (string) modularityConfig( + 'cms_routing.public_url_route_kind.' . $key, + UrlRoute::KIND_PAGE_PUBLIC + ); + + return app(CmsPublicModelResolver::class)->resolveForParentSegmentRegistry($request, $kind); + } + + /** + * Renders the same SEO/view payload as {@see CmsController} but the Blade is chosen from the resolved + * model type (submodule) instead of a fixed per-controller {@code module::route.custom}. + */ + protected function renderPublicCmsPresentation( + Request $request, + Model $item, + CanonicalUrlResolverInterface $canonical, + bool $forcePreviewRobotsNoIndex = false + ) { + $locale = app()->getLocale(); + $translation = method_exists($item, 'translate') ? $item->translate($locale) : null; + + $seo = CmsPublicSeo::build($request, $translation, $canonical); + if ($forcePreviewRobotsNoIndex) { + $seo['robotsMeta'] = 'noindex, nofollow'; + } + + $viewName = CmsPublicFrontViewName::forModel($item); + + return view($viewName, [ + 'item' => $item, + 'seoTitle' => $seo['title'], + 'seoDescription' => $seo['description'], + 'canonicalUrl' => $seo['canonicalUrl'], + 'robotsMeta' => $seo['robotsMeta'], + ]); + } +} diff --git a/modules/Cms/Http/Controllers/Front/HomepageTestController.php b/modules/Cms/Http/Controllers/Front/HomepageTestController.php new file mode 100644 index 000000000..7f6dc0373 --- /dev/null +++ b/modules/Cms/Http/Controllers/Front/HomepageTestController.php @@ -0,0 +1,16 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Front; + +class HomepageTestController extends CmsController +{ + /** + * @var string + */ + protected $moduleName = 'Cms'; + + /** + * @var string + */ + protected $routeName = 'HomepageTest'; +} diff --git a/modules/Cms/Http/Controllers/Front/PageController.php b/modules/Cms/Http/Controllers/Front/PageController.php new file mode 100644 index 000000000..67b7ae00f --- /dev/null +++ b/modules/Cms/Http/Controllers/Front/PageController.php @@ -0,0 +1,13 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Front; + +/** + * Public CMS {@see \Modules\Cms\Entities\Page} renderer (invokable). View: {@code cms::page.custom}. + */ +final class PageController extends CmsController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'Page'; +} diff --git a/modules/Cms/Http/Controllers/Front/PublicSitemapController.php b/modules/Cms/Http/Controllers/Front/PublicSitemapController.php new file mode 100644 index 000000000..b0b747032 --- /dev/null +++ b/modules/Cms/Http/Controllers/Front/PublicSitemapController.php @@ -0,0 +1,23 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Front; + +use Illuminate\Http\Response; +use Modules\Cms\Services\CmsSitemapCacheService; + +/** + * Serves the last **committed** sitemap from cache; rebuild via {@see \Modules\Cms\Jobs\RebuildCmsSitemapJob} or + * `cms:sitemap:rebuild` artisan. + */ +final class PublicSitemapController +{ + public function __invoke(CmsSitemapCacheService $cache): Response + { + $body = $cache->getCommittedXml(); + + return response($body, 200, [ + 'Content-Type' => 'application/xml; charset=UTF-8', + 'X-Robots-Tag' => 'all', + ]); + } +} diff --git a/modules/Cms/Http/Controllers/Front/RobotsTxtController.php b/modules/Cms/Http/Controllers/Front/RobotsTxtController.php new file mode 100644 index 000000000..3168e9b7e --- /dev/null +++ b/modules/Cms/Http/Controllers/Front/RobotsTxtController.php @@ -0,0 +1,59 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Front; + +use Illuminate\Http\Response; +use Illuminate\Routing\Controller; +use Modules\Cms\Services\CmsSiteSeoSettingsService; + +/** + * Serves global robots.txt at GET /robots.txt when the route is enabled. + * + * Resolution order: {@see CmsSiteSeoSettingsService::resolvedRobotsTxtBody()} (DB when enabled), then env/config. + */ +class RobotsTxtController extends Controller +{ + public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response + { + $body = self::resolvedBody($siteSeo); + + return response($body, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=3600', + ]); + } + + /** + * Normalized body (single trailing newline) for tests and reuse. + * + * Uses {@see CmsSiteSeoSettingsService} when the container can resolve it; otherwise env/config only + * (e.g. lightweight testbench without CMS bindings). + */ + public static function resolvedBody(?CmsSiteSeoSettingsService $siteSeo = null): string + { + if ($siteSeo instanceof CmsSiteSeoSettingsService) { + return $siteSeo->resolvedRobotsTxtBody(); + } + + if (function_exists('app') && app()->bound(CmsSiteSeoSettingsService::class)) { + return app(CmsSiteSeoSettingsService::class)->resolvedRobotsTxtBody(); + } + + return self::resolvedBodyFromConfigOnly(); + } + + /** + * Env/config fallback when DB-backed service is unavailable. + */ + public static function resolvedBodyFromConfigOnly(): string + { + $default = "User-agent: *\nAllow: /"; + $raw = trim((string) modularityConfig('cms_seo.robots.global_robots_txt', $default)); + + if ($raw === '') { + $raw = trim($default); + } + + return $raw . "\n"; + } +} diff --git a/modules/Cms/Http/Controllers/HomepageTestController.php b/modules/Cms/Http/Controllers/HomepageTestController.php new file mode 100644 index 000000000..f97b63bc9 --- /dev/null +++ b/modules/Cms/Http/Controllers/HomepageTestController.php @@ -0,0 +1,18 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Unusualify\Modularity\Http\Controllers\BaseController; + +class HomepageTestController extends BaseController +{ + /** + * @var string + */ + protected $moduleName = 'Cms'; + + /** + * @var string + */ + protected $routeName = 'HomepageTest'; +} diff --git a/modules/Cms/Http/Controllers/PageController.php b/modules/Cms/Http/Controllers/PageController.php new file mode 100644 index 000000000..8d6e9c248 --- /dev/null +++ b/modules/Cms/Http/Controllers/PageController.php @@ -0,0 +1,12 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Unusualify\Modularity\Http\Controllers\BaseController; + +class PageController extends BaseController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'Page'; +} diff --git a/modules/Cms/Http/Controllers/ParentSegmentController.php b/modules/Cms/Http/Controllers/ParentSegmentController.php new file mode 100644 index 000000000..cfe76ba0a --- /dev/null +++ b/modules/Cms/Http/Controllers/ParentSegmentController.php @@ -0,0 +1,12 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Unusualify\Modularity\Http\Controllers\BaseController; + +class ParentSegmentController extends BaseController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'ParentSegment'; +} diff --git a/modules/Cms/Http/Controllers/PromotionToolController.php b/modules/Cms/Http/Controllers/PromotionToolController.php new file mode 100644 index 000000000..5a894ceea --- /dev/null +++ b/modules/Cms/Http/Controllers/PromotionToolController.php @@ -0,0 +1,105 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Foundation\Application; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; +use Inertia\Inertia; +use Inertia\Response; +use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Http\Controllers\BaseController; + +/** + * Inertia shell for CMS promotion dry-run / execute (POST targets session web routes, not api/v1). + * Extends {@see BaseController} so {@see \Unusualify\Modularity\Http\Controllers\Traits\ManageInertia} + * supplies the same mainConfiguration / headLayoutData as index screens (sidebar, navigation). + */ +class PromotionToolController extends BaseController +{ + protected $moduleName = 'Cms'; + + /** + * Reuse the Page route binding so {@see \Modules\Cms\Repositories\PageRepository} resolves and + * BaseController preview/revision middleware checks do not run against a null repository. + */ + protected $routeName = 'Page'; + + public function __construct(Application $app, Request $request) + { + parent::__construct($app, $request); + } + + public function __invoke(Request $request): Response + { + $enabled = modularityConfig('cms_promotion.enabled', false); + + $pageTitle = __('CMS promotion') . ' - ' . Modularity::pageTitle(); + $headerTitle = __('CMS promotion'); + + $data = [ + 'pageTitle' => $pageTitle, + 'headerTitle' => $headerTitle, + '_mainConfiguration' => [ + 'navigation' => $this->promotionNavigationWithBreadcrumbs(), + ], + ]; + + $this->shareInertiaStoreVariables(); + + return Inertia::render('Promotion', [ + 'promotionDisabled' => ! $enabled, + 'promotionEndpoints' => $enabled ? $this->promotionSessionEndpoints() : ['dryRun' => '', 'execute' => ''], + 'defaultScope' => (array) modularityConfig('cms_promotion.scope', []), + 'endpoints' => new \stdClass, + 'mainConfiguration' => $this->getInertiaMainConfiguration($data), + 'headLayoutData' => $this->getHeadLayoutData($data), + ]); + } + + /** + * URLs for POST actions that run under {@see \Unusualify\Modularity\Facades\ModularityRoutes::webPanelMiddlewares()} + * so the panel session authenticates the request (see modules/Cms/Routes/web.php). + * + * @return array{dryRun: string, execute: string} + */ + protected function promotionSessionEndpoints(): array + { + $prefix = $this->module->panelRouteNamePrefix() . '.'; + + return [ + 'dryRun' => route($prefix . 'promotion.dryRun.web'), + 'execute' => route($prefix . 'promotion.execute.web'), + ]; + } + + /** + * Full navigation config with breadcrumbs for {@see get_modularity_inertia_main_configuration()}. + * + * @return array<string, mixed> + */ + protected function promotionNavigationWithBreadcrumbs(): array + { + $navigation = get_modularity_navigation_config(); + + $pageIndexRoute = $this->module->panelRouteNamePrefix() . '.page.index'; + $cmsCrumb = [ + 'title' => __('CMS'), + 'disabled' => true, + ]; + if (Route::has($pageIndexRoute)) { + $cmsCrumb['href'] = route($pageIndexRoute); + $cmsCrumb['disabled'] = false; + } + + $navigation['breadcrumbs'] = [ + $cmsCrumb, + [ + 'title' => __('CMS promotion'), + 'disabled' => true, + ], + ]; + + return $navigation; + } +} diff --git a/modules/Cms/Http/Controllers/RedirectController.php b/modules/Cms/Http/Controllers/RedirectController.php new file mode 100644 index 000000000..b7cdb3482 --- /dev/null +++ b/modules/Cms/Http/Controllers/RedirectController.php @@ -0,0 +1,383 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Route; +use Illuminate\Validation\ValidationException; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Contracts\RedirectValidationServiceInterface; +use Modules\Cms\Entities\Redirect; +use Unusualify\Modularity\Contracts\CanBulkSheet; +use Unusualify\Modularity\Http\Controllers\BaseController; +use Unusualify\Modularity\Support\ModularityFlashWarnings; + +class RedirectController extends BaseController implements CanBulkSheet +{ + private const CSV_ALLOWED_STATUS = [301, 302, 307, 308]; + + protected $moduleName = 'Cms'; + + protected $routeName = 'Redirect'; + + protected $fieldsPermissions = []; + + /** + * Non-blocking messages from {@see RedirectValidationServiceInterface::validate()} (e.g. cross-locale). + * + * @var array<int, string>|null + */ + protected ?array $redirectValidationWarnings = null; + + public function __construct(Application $app, Request $request) + { + if (modularityConfig('security.enabled', false)) { + $permission = modularityConfig('security.critical_field_permissions.redirect_from', 'redirect_edit'); + $this->fieldsPermissions = [ + 'from_path' => $permission, + 'to_path' => modularityConfig('security.critical_field_permissions.redirect_to', $permission), + 'status_code' => modularityConfig('security.critical_field_permissions.status_code', $permission), + ]; + } + + parent::__construct($app, $request); + } + + public function store($parentId = null) + { + $this->validateRedirectRules(); + + return $this->withRedirectValidationWarnings(parent::store($parentId)); + } + + public function update($id, $submoduleId = null) + { + $this->validateRedirectRules((int) $id); + + return $this->withRedirectValidationWarnings(parent::update($id, $submoduleId)); + } + + /** + * @param mixed $response + * @return mixed + */ + protected function withRedirectValidationWarnings($response) + { + $warnings = $this->redirectValidationWarnings ?? []; + $this->redirectValidationWarnings = null; + + if ($warnings === []) { + return $response; + } + + if ($response instanceof JsonResponse) { + $data = $response->getData(true); + if (is_array($data)) { + $existing = $data['warnings'] ?? []; + $data['warnings'] = array_values(array_merge( + is_array($existing) ? $existing : [], + array_values($warnings) + )); + $response->setData($data); + } + + return $response; + } + + if ($response instanceof RedirectResponse) { + ModularityFlashWarnings::merge($warnings); + } + + return $response; + } + + protected function validateRedirectRules(?int $id = null): void + { + if (! $this->request->has('from_path')) { + return; + } + + $fromPath = (string) $this->request->input('from_path', ''); + $toPath = (string) $this->request->input('to_path', ''); + $locale = (string) $this->request->input('locale', app()->getLocale()); + + /** @var RedirectValidationServiceInterface $validator */ + $redirectValidationService = app()->make(RedirectValidationServiceInterface::class); + + $existing = $this->repository->query() + ->when($id !== null, fn ($q) => $q->where('id', '<>', $id)) + ->get(['from_path', 'to_path']) + ->mapWithKeys(fn ($row) => [$row->from_path => $row->to_path]) + ->toArray(); + + $validation = $redirectValidationService->validate($fromPath, $toPath, [ + 'existing_redirects' => $existing, + 'locale' => $locale, + ]); + + $this->redirectValidationWarnings = array_values(array_filter( + $validation['warnings'] ?? [], + static fn ($w) => $w !== null && $w !== '' + )); + + if (! ($validation['valid'] ?? false)) { + throw ValidationException::withMessages([ + 'from_path' => $validation['errors'] ?? ['Invalid redirect rule.'], + ]); + } + } + + /** + * @return list<array{key: string, label: string, required?: bool, aliases?: list<string>}> + */ + public function bulkSheetFields(): array + { + return [ + ['key' => 'locale', 'label' => 'Locale', 'required' => true, 'aliases' => ['locale']], + ['key' => 'from_path', 'label' => 'From path', 'required' => true, 'aliases' => ['from', 'source']], + ['key' => 'to_path', 'label' => 'To path', 'required' => true, 'aliases' => ['to', 'target', 'destination']], + ['key' => 'status_code', 'label' => 'Status code', 'required' => false, 'aliases' => ['code']], + ['key' => 'is_active', 'label' => 'Active', 'required' => false, 'aliases' => ['active']], + ]; + } + + public function bulkSheetPrepareAndValidateRows(array $records): array + { + $canonical = $this->app->make(CanonicalUrlResolverInterface::class); + $redirectValidationService = $this->app->make(RedirectValidationServiceInterface::class); + /** @var Collection<int, Redirect> $dbRedirects */ + $dbRedirects = $this->repository->query()->get(); + + $result = []; + $batchNormKeys = []; + $priorEdges = []; + + foreach ($records as $record) { + $line = $record['line']; + $v = $record['values']; + $errors = []; + $warnings = []; + + $locale = $v['locale'] ?? ''; + if ($locale === '') { + $errors[] = 'locale is required.'; + } + + $fromRaw = $v['from_path'] ?? ''; + $toRaw = $v['to_path'] ?? ''; + if ($fromRaw === '') { + $errors[] = 'from_path is required.'; + } + if ($toRaw === '') { + $errors[] = 'to_path is required.'; + } + + $statusCode = 301; + if (isset($v['status_code']) && $v['status_code'] !== '') { + if (! ctype_digit($v['status_code'])) { + $errors[] = 'status_code must be an integer.'; + } else { + $statusCode = (int) $v['status_code']; + if (! in_array($statusCode, self::CSV_ALLOWED_STATUS, true)) { + $errors[] = 'status_code must be one of 301, 302, 307, 308.'; + } + } + } + + $isActive = true; + if (isset($v['is_active']) && $v['is_active'] !== '') { + $b = mb_strtolower(trim((string) $v['is_active'])); + if (! in_array($b, ['0', '1', 'true', 'false', 'yes', 'no', 'on', 'off'], true)) { + $errors[] = 'is_active must be 0, 1, true, false, yes, no, on, or off.'; + } else { + $isActive = ! in_array($b, ['0', 'false', 'no', 'off'], true); + } + } + + $normFrom = $fromRaw !== '' ? $canonical->normalizePath($fromRaw) : ''; + $normTo = $toRaw !== '' ? $canonical->normalizePath($toRaw) : ''; + + $graph = []; + foreach ($dbRedirects as $redirectRow) { + /** @var Redirect $redirectRow */ + if ($redirectRow->locale === $locale + && $canonical->normalizePath((string) $redirectRow->from_path) === $normFrom) { + continue; + } + $graph[$canonical->normalizePath((string) $redirectRow->from_path)] = $canonical->normalizePath((string) $redirectRow->to_path); + } + foreach ($priorEdges as $pf => $pt) { + $graph[$pf] = $pt; + } + + if ($errors === [] && $fromRaw !== '' && $toRaw !== '') { + $validation = $redirectValidationService->validate($fromRaw, $toRaw, [ + 'existing_redirects' => $graph, + 'locale' => $locale, + ]); + + if (! ($validation['valid'] ?? false)) { + $errors = array_merge($errors, $validation['errors'] ?? ['Invalid redirect.']); + } + $warnings = array_merge($warnings, $validation['warnings'] ?? []); + $normFrom = $validation['normalized']['from'] ?? $normFrom; + $normTo = $validation['normalized']['to'] ?? $normTo; + } + + $valid = $errors === []; + + $action = null; + if ($valid && $locale !== '' && $normFrom !== '') { + $dupKey = $locale . '|' . $normFrom; + if (isset($batchNormKeys[$dupKey])) { + $valid = false; + $errors[] = 'Duplicate locale + from_path in this file (see line ' . $batchNormKeys[$dupKey] . ').'; + } else { + $batchNormKeys[$dupKey] = $line; + $existing = $this->firstRedirectMatchingLocaleAndNormalizedFrom($locale, $normFrom, $canonical); + $action = $existing ? 'update' : 'create'; + $priorEdges[$normFrom] = $normTo; + } + } + + $result[] = [ + 'line' => $line, + 'locale' => $locale, + 'from_path' => $fromRaw, + 'to_path' => $toRaw, + 'status_code' => $statusCode, + 'is_active' => $isActive, + 'normalized' => ['from' => $normFrom, 'to' => $normTo], + 'valid' => $valid, + 'errors' => $errors, + 'warnings' => $warnings, + 'action' => $action, + ]; + } + + return $result; + } + + /** + * @param list<array<string, mixed>> $prepared + * @return array{created: int, updated: int} + */ + public function bulkSheetCommitPreparedRows(array $prepared): array + { + $canonical = $this->app->make(CanonicalUrlResolverInterface::class); + $created = 0; + $updated = 0; + + foreach ($prepared as $row) { + $fields = [ + 'from_path' => $row['normalized']['from'], + 'to_path' => $row['normalized']['to'], + 'locale' => $row['locale'], + 'status_code' => $row['status_code'], + 'is_active' => $row['is_active'], + ]; + + $existing = $this->firstRedirectMatchingLocaleAndNormalizedFrom( + (string) $row['locale'], + (string) $row['normalized']['from'], + $canonical + ); + + if ($existing instanceof Model) { + $this->repository->update($existing->getKey(), $fields); + $updated++; + } else { + $this->repository->create($fields); + $created++; + } + } + + return ['created' => $created, 'updated' => $updated]; + } + + /** + * @param resource $resource + */ + public function bulkSheetStreamExport($resource): void + { + $headers = []; + foreach ($this->bulkSheetFields() as $field) { + $aliases = array_values(array_unique(array_merge( + [$field['key']], + $field['aliases'] ?? [] + ))); + $headers[] = $aliases[0] ?? $field['key']; + } + fputcsv($resource, $headers); + + $this->repository->query() + ->orderBy('id') + ->chunk(500, function ($chunk) use ($resource): void { + foreach ($chunk as $redirect) { + /** @var Redirect $redirect */ + fputcsv($resource, [ + $redirect->locale, + $redirect->from_path, + $redirect->to_path, + $redirect->status_code, + $redirect->is_active ? '1' : '0', + ]); + } + }); + } + + private function firstRedirectMatchingLocaleAndNormalizedFrom( + string $locale, + string $normalizedFrom, + CanonicalUrlResolverInterface $canonical, + ): ?Redirect { + return $this->repository->query() + ->where('locale', $locale) + ->get() + ->first(fn (Redirect $r) => $canonical->normalizePath((string) $r->from_path) === $normalizedFrom); + } + + /** + * @return array<string, string> + */ + protected function bulkSheetInertiaUiStrings(): array + { + return [ + 'intro' => __('modules.cms.redirect.intro'), + 'headline' => __('modules.cms.redirect.headline'), + 'browser_title' => __('modules.cms.redirect.browser_title'), + ]; + } + + // /** + // * @return list<array{title: string, href?: string, disabled?: bool}> + // */ + // protected function bulkSheetBreadcrumbsItems(): array + // { + // $redirectIndex = $this->module->panelRouteNamePrefix() . '.' . snakeCase($this->routeName) . '.index'; + + // $cmsCrumb = [ + // 'title' => __('CMS'), + // ]; + + // $redirectCrumb = [ + // 'title' => __('Redirects'), + // ]; + // if (Route::has($redirectIndex)) { + // $redirectCrumb['href'] = route($redirectIndex); + // } + + // return [ + // $cmsCrumb, + // $redirectCrumb, + // [ + // 'title' => __('Import / export'), + // ], + // ]; + // } +} diff --git a/modules/Cms/Http/Controllers/SignedPublicPreviewMintController.php b/modules/Cms/Http/Controllers/SignedPublicPreviewMintController.php new file mode 100644 index 000000000..908a87335 --- /dev/null +++ b/modules/Cms/Http/Controllers/SignedPublicPreviewMintController.php @@ -0,0 +1,60 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Services\CmsSignedPreviewTargetResolver; +use Modules\Cms\Services\CmsSignedPreviewUrlGenerator; +use Unusualify\Modularity\Http\Controllers\Controller; + +/** + * Panel session GET: mints a time-limited signed CMS public preview URL ({@see cms.signed_preview.show}) for clipboard copy. + * + * @see ManageUtilities::signedPublicPreviewFormPayload() + * @see vue/src/js/hooks/useSignedPublicPreview.js + */ +final class SignedPublicPreviewMintController extends Controller +{ + public function __invoke( + Request $request, + CmsSignedPreviewTargetResolver $targetResolver, + CmsSignedPreviewUrlGenerator $urlGenerator, + CmsPublicModelResolver $publicModelResolver, + ): JsonResponse { + $moduleSeg = (string) $request->route('module'); + $routeSeg = (string) $request->route('route'); + $id = $request->route('id'); + + $target = $targetResolver->resolve($moduleSeg, $routeSeg); + if ($target === null) { + abort(404); + } + + $repository = $target['module']->getRepository($target['routeKey'], true); + $modelClass = get_class($repository->getModel()); + + $localeRaw = $request->query('locale'); + $locale = $localeRaw !== null && $localeRaw !== '' + ? (string) $localeRaw + : (string) modularityConfig('cms_routing.default_locale', config('app.locale')); + + $item = $publicModelResolver->resolveByIdBypassingPublicationScopes($modelClass, $id, $locale); + if ($item === null) { + abort(404); + } + + $url = $urlGenerator->temporaryAbsoluteUrl( + $target['module']->getName(), + $target['routeKey'], + $item, + $locale + ); + + return response()->json([ + 'url' => $url, + 'expiresInMinutes' => $urlGenerator->ttlMinutes(), + ]); + } +} diff --git a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php new file mode 100644 index 000000000..609cf0662 --- /dev/null +++ b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php @@ -0,0 +1,32 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Http\JsonResponse; +use Illuminate\Routing\Controller; +use Modules\Cms\Http\Requests\SiteSeoSettingsRequest; +use Modules\Cms\Services\CmsSiteSeoSettingsService; + +/** + * Persists site-wide SEO fields (session web route for the panel). + */ +class SiteSeoSettingsController extends Controller +{ + public function update(SiteSeoSettingsRequest $request, CmsSiteSeoSettingsService $service): JsonResponse + { + if (! modularityConfig('cms_seo.robots.use_site_settings', true)) { + return response()->json([ + 'ok' => false, + 'message' => __('Database-backed site SEO is disabled in configuration.'), + ], 422); + } + + $data = $request->validated(); + $service->saveGlobalRobotsTxt($data['global_robots_txt'] ?? null); + + return response()->json([ + 'ok' => true, + 'message' => __('Site SEO settings saved.'), + ]); + } +} diff --git a/modules/Cms/Http/Controllers/SiteSeoToolController.php b/modules/Cms/Http/Controllers/SiteSeoToolController.php new file mode 100644 index 000000000..12f370639 --- /dev/null +++ b/modules/Cms/Http/Controllers/SiteSeoToolController.php @@ -0,0 +1,84 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Foundation\Application; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; +use Inertia\Inertia; +use Inertia\Response; +use Modules\Cms\Services\CmsSiteSeoSettingsService; +use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Http\Controllers\BaseController; + +/** + * Inertia shell for CMS site-wide SEO (global robots.txt body stored in site_settings). + */ +class SiteSeoToolController extends BaseController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'Page'; + + public function __construct(Application $app, Request $request) + { + parent::__construct($app, $request); + } + + public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response + { + $pageTitle = __('Site SEO') . ' - ' . Modularity::pageTitle(); + $headerTitle = __('Site SEO'); + + $data = [ + 'pageTitle' => $pageTitle, + 'headerTitle' => $headerTitle, + '_mainConfiguration' => [ + 'navigation' => $this->siteSeoNavigationWithBreadcrumbs(), + ], + ]; + + $this->shareInertiaStoreVariables(); + + $prefix = $this->module->panelRouteNamePrefix() . '.'; + + return Inertia::render('SiteSeo', [ + 'siteSeoEndpoints' => [ + 'save' => route($prefix . 'siteSeo.save'), + ], + 'globalRobotsTxt' => $siteSeo->globalRobotsTxtForEditor(), + 'useSiteSettings' => (bool) modularityConfig('cms_seo.robots.use_site_settings', true), + 'endpoints' => new \stdClass, + 'mainConfiguration' => $this->getInertiaMainConfiguration($data), + 'headLayoutData' => $this->getHeadLayoutData($data), + ]); + } + + /** + * @return array<string, mixed> + */ + protected function siteSeoNavigationWithBreadcrumbs(): array + { + $navigation = get_modularity_navigation_config(); + + $pageIndexRoute = $this->module->panelRouteNamePrefix() . '.page.index'; + $cmsCrumb = [ + 'title' => __('CMS'), + 'disabled' => true, + ]; + if (Route::has($pageIndexRoute)) { + $cmsCrumb['href'] = route($pageIndexRoute); + $cmsCrumb['disabled'] = false; + } + + $navigation['breadcrumbs'] = [ + $cmsCrumb, + [ + 'title' => __('Site SEO'), + 'disabled' => true, + ], + ]; + + return $navigation; + } +} diff --git a/modules/Cms/Http/Controllers/SiteSettingController.php b/modules/Cms/Http/Controllers/SiteSettingController.php new file mode 100644 index 000000000..9d7f6a409 --- /dev/null +++ b/modules/Cms/Http/Controllers/SiteSettingController.php @@ -0,0 +1,12 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Unusualify\Modularity\Http\Controllers\BaseController; + +class SiteSettingController extends BaseController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'SiteSetting'; +} diff --git a/modules/Cms/Http/Controllers/SitemapController.php b/modules/Cms/Http/Controllers/SitemapController.php new file mode 100644 index 000000000..ceb5ace99 --- /dev/null +++ b/modules/Cms/Http/Controllers/SitemapController.php @@ -0,0 +1,93 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Route; +use Modules\Cms\Http\Requests\SitemapItemUpsertRequest; +use Modules\Cms\Http\Requests\SitemapRequest; +use Modules\Cms\Repositories\SitemapRepository; +use Unusualify\Modularity\Http\Controllers\BaseController; + +/** + * Modül Inertia index; özel arayüz {@see Sitemap/Index.vue}. JSON: {@see CmsSitemapPanelController} (dry-run, commit, item upsert). + */ +class SitemapController extends BaseController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'Sitemap'; + + /** + * @param \Illuminate\Http\Request $request + */ + public function indexData($request): array + { + $repo = $this->getRepository(); + if (! $repo instanceof SitemapRepository) { + return []; + } + $prefix = $this->module->panelRouteNamePrefix() . '.'; + + $breadcrumbs = [ + [ + 'title' => __('CMS'), + ], + [ + 'title' => __('Sitemap'), + ], + ]; + + return [ + 'tableAttributes' => [ + 'sitemapPanel' => [ + 'rows' => $repo->getSitemapItemRowsForPanel(), + 'publicSitemapUrl' => Route::has('cms.sitemap') ? route('cms.sitemap') : null, + ], + 'breadcrumbs' => $breadcrumbs, + ], + 'endpoints' => array_filter([ + 'sitemapDryRun' => route($prefix . 'sitemap.dryRun.web'), + 'sitemapCommit' => route($prefix . 'sitemap.commit.web'), + 'sitemapItemUpsert' => route($prefix . 'sitemap.item.upsert.web'), + ]), + ]; + } + + public function dryRun(SitemapRequest $request): JsonResponse + { + $repo = $this->resolveSitemapRepository(); + + return response()->json($repo->getPanelDryRunPayload()); + } + + public function commit(SitemapRequest $request): JsonResponse + { + $repo = $this->resolveSitemapRepository(); + + return response()->json($repo->commitSitemapToLiveCache()); + } + + public function upsertItem(SitemapItemUpsertRequest $request): JsonResponse + { + $repo = $this->resolveSitemapRepository(); + $v = $request->validated(); + + return response()->json($repo->upsertSitemapableItem( + (string) $v['sitemapable_type'], + (int) $v['sitemapable_id'], + (string) $v['changefreq'], + (string) $v['priority'], + )); + } + + private function resolveSitemapRepository(): SitemapRepository + { + $repo = $this->getRepository(); + if (! $repo instanceof SitemapRepository) { + abort(500, 'SitemapRepository is not bound for the Sitemap route.'); + } + + return $repo; + } +} diff --git a/modules/Cms/Http/Controllers/SitemapToolController.php b/modules/Cms/Http/Controllers/SitemapToolController.php new file mode 100644 index 000000000..00c784a81 --- /dev/null +++ b/modules/Cms/Http/Controllers/SitemapToolController.php @@ -0,0 +1,86 @@ +<?php + +namespace Modules\Cms\Http\Controllers; + +use Illuminate\Foundation\Application; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; +use Inertia\Inertia; +use Inertia\Response; +use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Http\Controllers\BaseController; + +/** + * @deprecated Prefer {@see SitemapController} and {@code modules/Cms/Resources/assets/Pages/Sitemap/Index.vue} (module Inertia index with row table; same panel JSON endpoints). + * Panel: sitemap dry-run (preview XML) and commit to cache. Legacy standalone Inertia page. + */ +class SitemapToolController extends BaseController +{ + protected $moduleName = 'Cms'; + + protected $routeName = 'Sitemap'; + + public function __construct(Application $app, Request $request) + { + parent::__construct($app, $request); + } + + public function __invoke(Request $request): Response + { + $pageTitle = __('Sitemap') . ' - ' . Modularity::pageTitle(); + $headerTitle = __('Sitemap'); + + $data = [ + 'pageTitle' => $pageTitle, + 'headerTitle' => $headerTitle, + '_mainConfiguration' => [ + 'navigation' => $this->sitemapNavigationWithBreadcrumbs(), + ], + ]; + + $this->shareInertiaStoreVariables(); + + $prefix = $this->module->panelRouteNamePrefix() . '.'; + + $publicSitemap = Route::has('cms.sitemap') ? route('cms.sitemap') : null; + + return Inertia::render('Sitemap', [ + 'sitemapEndpoints' => [ + 'dryRun' => route($prefix . 'sitemap.dryRun.web'), + 'commit' => route($prefix . 'sitemap.commit.web'), + ], + 'publicSitemapUrl' => $publicSitemap, + 'endpoints' => new \stdClass, + 'mainConfiguration' => $this->getInertiaMainConfiguration($data), + 'headLayoutData' => $this->getHeadLayoutData($data), + ]); + } + + /** + * @return array<string, mixed> + */ + protected function sitemapNavigationWithBreadcrumbs(): array + { + $navigation = get_modularity_navigation_config(); + + $pageIndexRoute = $this->module->panelRouteNamePrefix() . '.page.index'; + $cmsCrumb = [ + 'title' => __('CMS'), + 'disabled' => true, + ]; + if (Route::has($pageIndexRoute)) { + $cmsCrumb['href'] = route($pageIndexRoute); + $cmsCrumb['disabled'] = false; + } + + $navigation['breadcrumbs'] = [ + $cmsCrumb, + [ + 'title' => __('Sitemap'), + 'disabled' => true, + ], + ]; + + return $navigation; + } +} diff --git a/modules/Cms/Http/Controllers/Traits/ManageCms.php b/modules/Cms/Http/Controllers/Traits/ManageCms.php new file mode 100644 index 000000000..4a24de66d --- /dev/null +++ b/modules/Cms/Http/Controllers/Traits/ManageCms.php @@ -0,0 +1,180 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Traits; + +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Route; +use Modules\Cms\Entities\Concerns\HasParentSegment; +use Unusualify\Modularity\Entities\Traits\HasSlug; +use Unusualify\Modularity\Entities\Traits\IsSingular; +use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Support\ModularityFlashWarnings; + + +trait ManageCms +{ + protected $fieldsPermissions = []; + + /** + * @return void + */ + protected function __beforeConstructManageCms($app, $request) + { + if (modularityConfig('security.enabled', false)) { + $this->fieldsPermissions = [ + 'canonical_url' => modularityConfig('security.critical_field_permissions.canonical_url', 'page_edit'), + 'robots_index' => modularityConfig('security.critical_field_permissions.robots_index', 'page_edit'), + 'robots_follow' => modularityConfig('security.critical_field_permissions.robots_follow', 'page_edit'), + ]; + } + } + + protected function handleResponseManageCms($response) + { + $repo = $this->repository; + $warnings = is_object($repo) && method_exists($repo, 'pullCmsAdminWarnings') + ? $repo->pullCmsAdminWarnings() + : []; + + if ($warnings === []) { + return $response; + } + + if ($response instanceof JsonResponse) { + $data = $response->getData(true); + if (is_array($data)) { + $existing = $data['warnings'] ?? []; + $data['warnings'] = array_values(array_merge( + is_array($existing) ? $existing : [], + $warnings + )); + $response->setData($data); + } + + return $response; + } + + if ($response instanceof RedirectResponse) { + ModularityFlashWarnings::merge($warnings); + } + + return $response; + } + + + /** + * Metadata for minting a signed public preview URL (any submodule with {@see HasParentSegment} + CMS front stack). + * + * @return array{fetchUrl: string, expiresInMinutes: int}|null + */ + protected function signedPublicPreviewFormPayload($itemId): ?array + { + if ($itemId === null || $itemId === '') { + return null; + } + + if (! modularityConfig('cms_routing.signed_preview.enabled', true)) { + return null; + } + + if (! class_exists(\Modules\Cms\Services\CmsSignedPreviewTargetResolver::class)) { + return null; + } + + $model = $this->repository->getModel(); + if (! classHasTrait($model, HasParentSegment::class)) { + return null; + } + + $target = app(\Modules\Cms\Services\CmsSignedPreviewTargetResolver::class) + ->resolve((string) $this->moduleName, (string) $this->routeName); + if ($target === null) { + return null; + } + + $cmsModule = Collection::make(Modularity::allEnabled())->first( + fn ($m) => studlyName($m->getName()) === 'Cms' + ); + if ($cmsModule === null) { + return null; + } + + $mintRouteKey = $cmsModule->panelRouteNamePrefix() . '.signed_public_preview.mint'; + if (! Route::has($mintRouteKey)) { + return null; + } + + return [ + 'fetchUrl' => route($mintRouteKey, [ + 'module' => $this->moduleName, + 'route' => $this->routeName, + 'id' => $itemId, + ], false), + 'expiresInMinutes' => app(\Modules\Cms\Services\CmsSignedPreviewUrlGenerator::class)->ttlMinutes(), + ]; + } + + /** + * Public site URLs for the edited item (per locale), for the admin form permalink row. + * + * @return list<array{locale: string, path: string, url: string}>|null + */ + protected function localizedPublicPermalinksForFormItem($item): ?array + { + if (! is_object($item) || $item->getKey() === null) { + return null; + } + + if (! modularityConfig('cms_routing.public_pages_enabled', true)) { + return null; + } + + $modelClass = get_class($item); + + if (! classHasTrait($modelClass, HasParentSegment::class) + || ! (classHasTrait($modelClass, HasSlug::class) || classHasTrait($modelClass, IsSingular::class)) + ) { + return null; + } + + if (! class_exists(\Modules\Cms\Services\CmsUrlRouteRegistry::class)) { + return null; + } + + $registry = app(\Modules\Cms\Services\CmsUrlRouteRegistry::class); + + if (! $registry->tableReady()) { + return null; + } + + $byLocale = $registry->publicPagePathsByLocale($item); + + if ($byLocale === []) { + return null; + } + + $out = []; + + foreach ($byLocale as $locale => $path) { + $locale = (string) $locale; + $path = (string) $path; + + if (trim($path) === '') { + continue; + } + + $browserPath = \Modules\Cms\Support\CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath($locale, $path); + + $out[] = [ + 'locale' => $locale, + 'path' => $browserPath, + 'url' => \Modules\Cms\Support\CmsPublicSiteUrl::absoluteUrlForPath($browserPath), + ]; + } + + return $out === [] ? null : $out; + } + +} diff --git a/modules/Cms/Http/Controllers/Traits/ResolvesPublicPresentationView.php b/modules/Cms/Http/Controllers/Traits/ResolvesPublicPresentationView.php new file mode 100644 index 000000000..2726bfaaa --- /dev/null +++ b/modules/Cms/Http/Controllers/Traits/ResolvesPublicPresentationView.php @@ -0,0 +1,73 @@ +<?php + +namespace Modules\Cms\Http\Controllers\Traits; + +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Str; + +/** + * Single place to resolve the Blade used for admin preview and public front when they must match + * (module::route.custom convention or legacy site.{singular module}). + */ +trait ResolvesPublicPresentationView +{ + /** + * Optional override for the Blade used for public display and admin preview. + * When null or empty, {@see presentationViewName()} uses {@see presentationViewPrefix()} + ".custom" + * or the legacy {@code modularity.frontend.views_path}.{singular module}. + * + * @var string|null + */ + public $previewView = null; + + /** + * Snake-case module::route namespace (e.g. cms::page), aligned with + * {@see \Unusualify\Modularity\Http\Controllers\BaseController::getViewPrefix()}. + */ + protected function presentationViewPrefix(): string + { + $module = $this->getModuleName(); + $route = $this->getRouteName(); + + if ($module === null || $route === null || $module === '' || $route === '') { + return ''; + } + + return Str::snake($module) . '::' . Str::snake($route); + } + + /** + * Dot-separated route-name prefix (e.g. cms.page) for public helpers; mirrors admin + * {@see \Unusualify\Modularity\Http\Controllers\PanelController::$routePrefix} shape. + */ + protected function presentationRoutePrefix(): string + { + $module = $this->getModuleName(); + $route = $this->getRouteName(); + + if ($module === null || $route === null || $module === '' || $route === '') { + return ''; + } + + return Str::snake($module) . '.' . Str::snake($route); + } + + /** + * Blade view name shared by admin preview and public CMS when using the same presentation. + */ + protected function presentationViewName(): string + { + if ($this->previewView !== null && $this->previewView !== '') { + return $this->previewView; + } + + $prefix = $this->presentationViewPrefix(); + if ($prefix !== '') { + return $prefix . '.custom'; + } + + $moduleKey = $this->getModuleName() ?? ''; + + return Config::get('modularity.frontend.views_path', 'site') . '.' . Str::singular($moduleKey); + } +} diff --git a/modules/Cms/Http/Middleware/CanonicalLocaleMiddleware.php b/modules/Cms/Http/Middleware/CanonicalLocaleMiddleware.php new file mode 100644 index 000000000..8deecee71 --- /dev/null +++ b/modules/Cms/Http/Middleware/CanonicalLocaleMiddleware.php @@ -0,0 +1,33 @@ +<?php + +namespace Modules\Cms\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; + +class CanonicalLocaleMiddleware +{ + public function __construct( + protected CanonicalUrlResolverInterface $canonicalUrlResolver, + ) {} + + public function handle(Request $request, Closure $next) + { + if (! modularityConfig('cms_routing.redirect_to_canonical', false)) { + return $next($request); + } + + $resolved = $this->canonicalUrlResolver->resolve( + host: $request->getHost(), + path: $request->getPathInfo(), + locale: app()->getLocale(), + ); + + if (($resolved['should_redirect'] ?? false) === true) { + return redirect()->to($resolved['redirect_to'], 301); + } + + return $next($request); + } +} diff --git a/modules/Cms/Http/Middleware/FallbackLocaleSluglessCanonicalMiddleware.php b/modules/Cms/Http/Middleware/FallbackLocaleSluglessCanonicalMiddleware.php new file mode 100644 index 000000000..5d6c5e553 --- /dev/null +++ b/modules/Cms/Http/Middleware/FallbackLocaleSluglessCanonicalMiddleware.php @@ -0,0 +1,64 @@ +<?php + +namespace Modules\Cms\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Services\CmsVisitorRedirectResolver; +use Modules\Cms\Support\CmsSluglessFallbackLocale; + +/** + * When {@see CmsSluglessFallbackLocale::enabled()}, redirects {@code GET /{sluglessLocale}/{rest}} to {@code GET /{rest}} + * whenever {@see \Modules\Cms\Entities\UrlRoute} already serves {@code PAGE_PUBLIC} for that locale + inner path. + * + * @see \Modules\Cms\Support\CmsSluglessFallbackLocale + * @see \Modules\Cms\Routing\CmsFrontRouteRegistrar::resolveMiddlewareStack + */ +final class FallbackLocaleSluglessCanonicalMiddleware +{ + public function __construct( + private CanonicalUrlResolverInterface $canonicalUrlResolver, + private CmsVisitorRedirectResolver $visitorRedirectResolver, + ) {} + + public function handle(Request $request, Closure $next) + { + if ( + ! CmsSluglessFallbackLocale::enabled() + || ! modularityConfig('cms_routing.public_pages_enabled', true) + ) { + return $next($request); + } + + if (! in_array(mb_strtoupper($request->method()), ['GET', 'HEAD'], true)) { + return $next($request); + } + + if ($this->visitorRedirectResolver->shouldExcludeRequest($request)) { + return $next($request); + } + + [$locale, $pathKey, $explicitLocalePrefix] = $this->visitorRedirectResolver->resolveLocalePathKeyAndExplicitFlag($request); + + if (! $explicitLocalePrefix) { + return $next($request); + } + + if (! CmsSluglessFallbackLocale::sameLocale($locale, CmsSluglessFallbackLocale::resolvedCode())) { + return $next($request); + } + + if (! $this->visitorRedirectResolver->isActivePagePath((string) $locale, $pathKey, true)) { + return $next($request); + } + + $destination = $this->canonicalUrlResolver->normalizePath($pathKey); + $qs = $request->getQueryString(); + if (is_string($qs) && $qs !== '') { + $destination .= '?' . $qs; + } + + return redirect()->to($destination, CmsSluglessFallbackLocale::explicitSegmentRedirectStatus()); + } +} diff --git a/modules/Cms/Http/Middleware/VisitorRedirectMiddleware.php b/modules/Cms/Http/Middleware/VisitorRedirectMiddleware.php new file mode 100644 index 000000000..b937fa28f --- /dev/null +++ b/modules/Cms/Http/Middleware/VisitorRedirectMiddleware.php @@ -0,0 +1,34 @@ +<?php + +namespace Modules\Cms\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; +use Modules\Cms\Services\CmsVisitorRedirectResolver; + +/** + * Applies CMS {@see \Modules\Cms\Entities\Redirect} rules to public (front) requests. + * + * Run after {@see CanonicalLocaleMiddleware} when canonical URL enforcement is enabled, + * so the path shape is stable before matching stored rules. + */ +class VisitorRedirectMiddleware +{ + public function __construct( + private CmsVisitorRedirectResolver $resolver, + ) {} + + public function handle(Request $request, Closure $next) + { + if (! modularityConfig('cms_routing.visitor_redirects_enabled', true)) { + return $next($request); + } + + $response = $this->resolver->resolveRedirectResponse($request); + if ($response !== null) { + return $response; + } + + return $next($request); + } +} diff --git a/modules/Cms/Http/Requests/.gitkeep b/modules/Cms/Http/Requests/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Http/Requests/HomepageTestRequest.php b/modules/Cms/Http/Requests/HomepageTestRequest.php new file mode 100644 index 000000000..f2527d737 --- /dev/null +++ b/modules/Cms/Http/Requests/HomepageTestRequest.php @@ -0,0 +1,39 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Unusualify\Modularity\Http\Requests\Request; + +class HomepageTestRequest extends Request +{ + /** + * Get the default validation rules that apply to the request. + * + * @return array + */ + public function rulesForAll() + { + return [ + ]; + } + + /** + * Get the validation rules that apply to the post request. + * + * @return array + */ + public function rulesForCreate() + { + return []; + } + + /** + * Get the validation rules that apply to the put/patch request. + * + * @return array + */ + public function rulesForUpdate() + { + return []; + } +} diff --git a/modules/Cms/Http/Requests/PageRequest.php b/modules/Cms/Http/Requests/PageRequest.php new file mode 100644 index 000000000..6a1f4361e --- /dev/null +++ b/modules/Cms/Http/Requests/PageRequest.php @@ -0,0 +1,80 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Closure; +use Unusualify\Modularity\Http\Requests\Request; + +class PageRequest extends Request +{ + public function rulesForAll() + { + return [ + 'layout' => 'nullable|string|max:120', + 'schema' => 'nullable|array', + 'title' => 'sometimes|required|string|max:255', + /** + * Slug input (SlugHydrate) sends `{ slug: string, active: bool }` per locale when manageActive is true; + * legacy payloads may still send a plain string. + */ + // 'slug_segment' => [ + // 'sometimes', + // 'required', + // fn (string $attribute, mixed $value, Closure $fail) => $this->validateSlugSegmentLocaleValue($attribute, $value, $fail), + // ], + // 'seo_title' => 'nullable|string|max:255', + // 'seo_description' => 'nullable|string|max:255', + // 'canonical_url' => 'nullable|url|max:255', + // 'robots_index' => 'nullable|boolean', + // 'robots_follow' => 'nullable|boolean', + 'publish_start_date' => 'nullable|date', + 'publish_end_date' => 'nullable|date', + ]; + } + + public function rulesForCreate() + { + return []; + } + + public function rulesForUpdate() + { + return []; + } + + /** + * @param \Closure(string): void $fail + */ + protected function validateSlugSegmentLocaleValue(string $attribute, mixed $value, Closure $fail): void + { + $text = $this->extractSlugSegmentText($value); + if ($text === null) { + $fail(__('validation.string', ['attribute' => $attribute])); + + return; + } + + if (mb_strlen($text) > 255) { + $fail(__('validation.max.string', ['attribute' => $attribute, 'max' => 255])); + } + } + + protected function extractSlugSegmentText(mixed $value): ?string + { + if (is_array($value) && array_key_exists('slug', $value)) { + $slug = $value['slug']; + + return $slug === null || $slug === '' ? '' : (string) $slug; + } + + if ($value === null) { + return ''; + } + + if (is_string($value) || is_numeric($value)) { + return (string) $value; + } + + return null; + } +} diff --git a/modules/Cms/Http/Requests/ParentSegmentRequest.php b/modules/Cms/Http/Requests/ParentSegmentRequest.php new file mode 100644 index 000000000..2f4e9df0a --- /dev/null +++ b/modules/Cms/Http/Requests/ParentSegmentRequest.php @@ -0,0 +1,45 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Illuminate\Foundation\Http\FormRequest; + +class ParentSegmentRequest extends FormRequest +{ + /** + * @var array<string, string> + */ + protected array $schemaRules = []; + + public function __construct( + array $rules = [], + array $query = [], + array $request = [], + array $attributes = [], + array $cookies = [], + array $files = [], + array $server = [], + $content = null + ) { + parent::__construct($query, $request, $attributes, $cookies, $files, $server, $content); + $this->schemaRules = $rules; + } + + /** + * @return array<string, string> + */ + public function rules(): array + { + return array_merge( + $this->schemaRules, + [ + 'target_model_class' => 'sometimes|required|string|max:512', + 'locale' => 'nullable|string|max:12', + 'normalized_prefix' => 'sometimes|nullable|string|max:2048', + 'admin_label' => 'nullable|string|max:255', + 'enabled' => 'sometimes|boolean', + 'sort_order' => 'sometimes|integer|min:0', + ] + ); + } +} diff --git a/modules/Cms/Http/Requests/RedirectRequest.php b/modules/Cms/Http/Requests/RedirectRequest.php new file mode 100644 index 000000000..83664c370 --- /dev/null +++ b/modules/Cms/Http/Requests/RedirectRequest.php @@ -0,0 +1,29 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Unusualify\Modularity\Http\Requests\Request; + +class RedirectRequest extends Request +{ + public function rulesForAll() + { + return [ + 'from_path' => 'sometimes|required|string|max:255', + 'to_path' => 'sometimes|required|string|max:255|different:from_path', + 'locale' => 'sometimes|required|string|max:12', + 'status_code' => 'sometimes|required|integer|in:301,302,307,308', + 'is_active' => 'sometimes|nullable|boolean', + ]; + } + + public function rulesForCreate() + { + return []; + } + + public function rulesForUpdate() + { + return []; + } +} diff --git a/modules/Cms/Http/Requests/SiteSeoSettingsRequest.php b/modules/Cms/Http/Requests/SiteSeoSettingsRequest.php new file mode 100644 index 000000000..bb430e5a7 --- /dev/null +++ b/modules/Cms/Http/Requests/SiteSeoSettingsRequest.php @@ -0,0 +1,25 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Unusualify\Modularity\Http\Requests\Request; + +class SiteSeoSettingsRequest extends Request +{ + public function rulesForAll() + { + return [ + 'global_robots_txt' => 'nullable|string|max:200000', + ]; + } + + public function rulesForCreate() + { + return []; + } + + public function rulesForUpdate() + { + return []; + } +} diff --git a/modules/Cms/Http/Requests/SiteSettingRequest.php b/modules/Cms/Http/Requests/SiteSettingRequest.php new file mode 100644 index 000000000..bf28128cd --- /dev/null +++ b/modules/Cms/Http/Requests/SiteSettingRequest.php @@ -0,0 +1,29 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Unusualify\Modularity\Http\Requests\Request; + +class SiteSettingRequest extends Request +{ + public function rulesForAll() + { + return [ + 'group_key' => 'required|string|max:100', + 'key' => 'required|string|max:100', + 'locale' => 'required|string|max:12', + 'value' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]; + } + + public function rulesForCreate() + { + return []; + } + + public function rulesForUpdate() + { + return []; + } +} diff --git a/modules/Cms/Http/Requests/SitemapItemUpsertRequest.php b/modules/Cms/Http/Requests/SitemapItemUpsertRequest.php new file mode 100644 index 000000000..534b0b5d3 --- /dev/null +++ b/modules/Cms/Http/Requests/SitemapItemUpsertRequest.php @@ -0,0 +1,34 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Unusualify\Modularity\Http\Requests\Request; + +/** + * Panel JSON POST to upsert {@see \Modules\Cms\Entities\CmsSitemapableItem} (per public page / urlable). + */ +class SitemapItemUpsertRequest extends Request +{ + /** + * @return array<string, mixed> + */ + public function rulesForAll(): array + { + return [ + 'sitemapable_type' => 'required|string|max:512', + 'sitemapable_id' => 'required|integer|min:1', + 'changefreq' => 'required|string|in:always,hourly,daily,weekly,monthly,yearly,never', + 'priority' => 'required|numeric|min:0|max:1', + ]; + } + + public function rulesForCreate(): array + { + return []; + } + + public function rulesForUpdate(): array + { + return []; + } +} diff --git a/modules/Cms/Http/Requests/SitemapRequest.php b/modules/Cms/Http/Requests/SitemapRequest.php new file mode 100644 index 000000000..a35693abc --- /dev/null +++ b/modules/Cms/Http/Requests/SitemapRequest.php @@ -0,0 +1,28 @@ +<?php + +namespace Modules\Cms\Http\Requests; + +use Unusualify\Modularity\Http\Requests\Request; + +/** + * Panel JSON POST for {@see \Modules\Cms\Http\Controllers\CmsSitemapPanelController} (body genelde boş; ileri alanlar için genişletilebilir). + */ +class SitemapRequest extends Request +{ + public function rulesForAll() + { + return [ + // Reserved for future: 'force' => 'sometimes|boolean', + ]; + } + + public function rulesForCreate() + { + return []; + } + + public function rulesForUpdate() + { + return []; + } +} diff --git a/modules/Cms/Jobs/PromoteCmsReleaseJob.php b/modules/Cms/Jobs/PromoteCmsReleaseJob.php new file mode 100644 index 000000000..04a37b5c0 --- /dev/null +++ b/modules/Cms/Jobs/PromoteCmsReleaseJob.php @@ -0,0 +1,29 @@ +<?php + +namespace Modules\Cms\Jobs; + +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Modules\Cms\Services\CmsPromotionService; + +class PromoteCmsReleaseJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + /** + * @param array<string, mixed> $payload `scope`, optional `user_id` for audit / activity attribution + */ + public function __construct( + public array $payload = [], + ) {} + + public function handle(CmsPromotionService $promotionService): void + { + $payload = array_merge($this->payload, ['dry_run' => false]); + + $promotionService->promote($payload, null); + } +} diff --git a/modules/Cms/Jobs/RebuildCmsSitemapJob.php b/modules/Cms/Jobs/RebuildCmsSitemapJob.php new file mode 100644 index 000000000..dfa4a430e --- /dev/null +++ b/modules/Cms/Jobs/RebuildCmsSitemapJob.php @@ -0,0 +1,22 @@ +<?php + +namespace Modules\Cms\Jobs; + +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Modules\Cms\Services\CmsSitemapBuildService; +use Modules\Cms\Services\CmsSitemapCacheService; + +final class RebuildCmsSitemapJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function handle(CmsSitemapBuildService $build, CmsSitemapCacheService $cache): void + { + $xml = $build->buildXml(); + $cache->commit($xml); + } +} diff --git a/modules/Cms/Jobs/ScanCmsPublishWindowBoundariesJob.php b/modules/Cms/Jobs/ScanCmsPublishWindowBoundariesJob.php new file mode 100644 index 000000000..f691cd18a --- /dev/null +++ b/modules/Cms/Jobs/ScanCmsPublishWindowBoundariesJob.php @@ -0,0 +1,140 @@ +<?php + +namespace Modules\Cms\Jobs; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; +use Modules\Cms\Entities\Page; +use Modules\Cms\Events\CmsPublishWindowBoundaryReached; + +/** + * Scans configured Eloquent models for recent {@code publish_start_date} / {@code publish_end_date} boundary crossings + * and dispatches {@see CmsPublishWindowBoundaryReached}. + */ +final class ScanCmsPublishWindowBoundariesJob +{ + use Dispatchable; + + public function handle(): void + { + if (! modularityConfig('cms_features.enabled', true)) { + return; + } + + if (! modularityConfig('cms_schedule.enabled', true)) { + return; + } + + $window = max(1, (int) modularityConfig('cms_schedule.boundary_window_minutes', 6)); + $since = now()->subMinutes($window); + $until = now(); + + $fired = false; + + foreach ($this->resolvePublishWindowModelClasses() as $modelClass) { + $instance = new $modelClass; + $table = $instance->getTable(); + if (! Schema::hasTable($table)) { + continue; + } + + $keyName = $instance->getKeyName(); + + if (Schema::hasColumn($table, 'publish_start_date')) { + $startIds = $modelClass::query() + ->whereNotNull('publish_start_date') + ->whereBetween('publish_start_date', [$since, $until]) + ->pluck($keyName) + ->all(); + + foreach ($startIds as $id) { + event(new CmsPublishWindowBoundaryReached($modelClass, $id, 'publish_start')); + $fired = true; + $this->maybeLog($modelClass, $id, 'publish_start'); + } + } + + if (Schema::hasColumn($table, 'publish_end_date')) { + $endIds = $modelClass::query() + ->whereNotNull('publish_end_date') + ->whereBetween('publish_end_date', [$since, $until]) + ->pluck($keyName) + ->all(); + + foreach ($endIds as $id) { + event(new CmsPublishWindowBoundaryReached($modelClass, $id, 'publish_end')); + $fired = true; + $this->maybeLog($modelClass, $id, 'publish_end'); + } + } + } + + if ($fired) { + $this->maybeFlushCacheTags(); + } + } + + /** + * @return list<class-string<Model>> + */ + private function resolvePublishWindowModelClasses(): array + { + $configured = modularityConfig('cms_schedule.publish_window_models', null); + + if ($configured === null) { + return [Page::class]; + } + + if (! is_array($configured)) { + return []; + } + + $out = []; + foreach ($configured as $class) { + if (! is_string($class) || $class === '' || ! class_exists($class)) { + continue; + } + if (! is_subclass_of($class, Model::class, true)) { + continue; + } + $out[] = $class; + } + + return array_values(array_unique($out)); + } + + private function maybeLog(string $modelClass, int|string $modelId, string $boundary): void + { + if (! modularityConfig('cms_schedule.log_events', false)) { + return; + } + + Log::info('CMS publish window boundary', [ + 'model' => $modelClass, + 'model_id' => $modelId, + 'boundary' => $boundary, + ]); + } + + private function maybeFlushCacheTags(): void + { + $tags = modularityConfig('cms_schedule.cache_flush_tags'); + if (! is_array($tags) || $tags === []) { + return; + } + + $store = Cache::getStore(); + if (! method_exists($store, 'tags')) { + return; + } + + try { + Cache::tags($tags)->flush(); + } catch (\Throwable) { + // Taggable store not available or driver unsupported + } + } +} diff --git a/modules/Cms/Localization/DelegatingCmsLocalizationAdapter.php b/modules/Cms/Localization/DelegatingCmsLocalizationAdapter.php new file mode 100644 index 000000000..a4520bfa0 --- /dev/null +++ b/modules/Cms/Localization/DelegatingCmsLocalizationAdapter.php @@ -0,0 +1,59 @@ +<?php + +namespace Modules\Cms\Localization; + +use Modules\Cms\Contracts\CmsLocalizationContract; +use Modules\Cms\Contracts\CmsLocalizationOverrideProviderInterface; + +/** + * Applies optional {@see CmsLocalizationOverrideProviderInterface} (DB / SiteSetting) on top of a concrete driver. + */ +final class DelegatingCmsLocalizationAdapter implements CmsLocalizationContract +{ + public function __construct( + private CmsLocalizationContract $inner, + private CmsLocalizationOverrideProviderInterface $overrides, + ) {} + + public function driver(): string + { + return $this->inner->driver(); + } + + public function pathSegmentLocales(): array + { + return $this->overrides->pathSegmentLocales() ?? $this->inner->pathSegmentLocales(); + } + + public function supportedLocalesMeta(): array + { + return $this->overrides->supportedLocalesMeta() ?? $this->inner->supportedLocalesMeta(); + } + + public function defaultLocale(): string + { + return $this->overrides->defaultLocale() ?? $this->inner->defaultLocale(); + } + + public function hideDefaultLocaleInUrl(): bool + { + return $this->overrides->hideDefaultLocaleInUrl() ?? $this->inner->hideDefaultLocaleInUrl(); + } + + public function localizeUrl(?string $url = null, ?string $locale = null): string + { + $resolvedLocale = $locale ?? $this->defaultLocale(); + + return $this->inner->localizeUrl($url, $resolvedLocale); + } + + public function stripLocaleFromUrl(?string $url = null): string + { + return $this->inner->stripLocaleFromUrl($url); + } + + public function applyLocaleToApplication(string $locale): void + { + $this->inner->applyLocaleToApplication($locale); + } +} diff --git a/modules/Cms/Localization/McamaraCmsLocalizationAdapter.php b/modules/Cms/Localization/McamaraCmsLocalizationAdapter.php new file mode 100644 index 000000000..0f09a31ff --- /dev/null +++ b/modules/Cms/Localization/McamaraCmsLocalizationAdapter.php @@ -0,0 +1,147 @@ +<?php + +namespace Modules\Cms\Localization; + +use Mcamara\LaravelLocalization\Facades\LaravelLocalization; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Contracts\CmsLocalizationContract; +use Modules\Cms\Support\CmsPathLocale; + +/** + * Uses {@see LaravelLocalization} for locale lists, URL helpers, and hide-default behaviour. + */ +final class McamaraCmsLocalizationAdapter implements CmsLocalizationContract +{ + public function __construct( + private CanonicalUrlResolverInterface $canonical, + ) {} + + public function driver(): string + { + return 'mcamara'; + } + + public function pathSegmentLocales(): array + { + $configured = modularityConfig('cms_routing.path_segment_locales'); + if (is_array($configured) && $configured !== []) { + return $this->sortedLongestFirst(array_values(array_unique(array_filter(array_map('strval', $configured))))); + } + + $mcamaraKeys = null; + + try { + $maybe = LaravelLocalization::getSupportedLanguagesKeys(); + if (is_array($maybe) && $maybe !== []) { + $mcamaraKeys = $maybe; + } + } catch (\Throwable) { + } + + return CmsPathLocale::mergeMcamaraKeysWithSiteLocales($mcamaraKeys); + } + + public function supportedLocalesMeta(): array + { + try { + $locales = LaravelLocalization::getSupportedLocales(); + if (is_array($locales) && $locales !== []) { + return $locales; + } + } catch (\Throwable) { + } + + return $this->minimalMetaForKeys($this->pathSegmentLocales()); + } + + public function defaultLocale(): string + { + $cms = (string) modularityConfig('cms_routing.default_locale', ''); + if ($cms !== '') { + return $cms; + } + + try { + $fromPackage = config('laravellocalization.locale'); + if (is_string($fromPackage) && $fromPackage !== '') { + return $fromPackage; + } + } catch (\Throwable) { + } + + return (string) config('app.locale'); + } + + public function hideDefaultLocaleInUrl(): bool + { + $source = (string) modularityConfig('cms_routing.localization_hide_default_source', 'mcamara'); + + if ($source === 'cms') { + return (bool) modularityConfig('cms_routing.hide_default_locale_segment', false); + } + + try { + $mcamara = (bool) config('laravellocalization.hideDefaultLocaleInURL', false); + if ($source === 'mcamara') { + return $mcamara; + } + + return $mcamara || (bool) modularityConfig('cms_routing.hide_default_locale_segment', false); + } catch (\Throwable) { + return (bool) modularityConfig('cms_routing.hide_default_locale_segment', false); + } + } + + public function localizeUrl(?string $url = null, ?string $locale = null): string + { + try { + return (string) LaravelLocalization::getLocalizedURL($locale, $url); + } catch (\Throwable) { + return (new TranslatableCmsLocalizationAdapter($this->canonical))->localizeUrl($url, $locale); + } + } + + public function stripLocaleFromUrl(?string $url = null): string + { + try { + return (string) LaravelLocalization::getNonLocalizedURL($url); + } catch (\Throwable) { + return (new TranslatableCmsLocalizationAdapter($this->canonical))->stripLocaleFromUrl($url); + } + } + + public function applyLocaleToApplication(string $locale): void + { + app()->setLocale($locale); + } + + /** + * @param list<string> $keys + * @return array<string, array<string, mixed>> + */ + private function minimalMetaForKeys(array $keys): array + { + $meta = []; + foreach ($keys as $locale) { + $meta[$locale] = [ + 'name' => $locale, + 'native' => $locale, + 'script' => 'Latn', + 'regional' => '', + ]; + } + + return $meta; + } + + /** + * @param list<string> $locales + * @return list<string> + */ + private function sortedLongestFirst(array $locales): array + { + usort($locales, fn (string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a)); + + return $locales; + } +} diff --git a/modules/Cms/Localization/NullCmsLocalizationOverrideProvider.php b/modules/Cms/Localization/NullCmsLocalizationOverrideProvider.php new file mode 100644 index 000000000..a8768e19c --- /dev/null +++ b/modules/Cms/Localization/NullCmsLocalizationOverrideProvider.php @@ -0,0 +1,31 @@ +<?php + +namespace Modules\Cms\Localization; + +use Modules\Cms\Contracts\CmsLocalizationOverrideProviderInterface; + +/** + * No DB overrides; inner {@see \Modules\Cms\Contracts\CmsLocalizationContract} values are used as-is. + */ +final class NullCmsLocalizationOverrideProvider implements CmsLocalizationOverrideProviderInterface +{ + public function pathSegmentLocales(): ?array + { + return null; + } + + public function defaultLocale(): ?string + { + return null; + } + + public function hideDefaultLocaleInUrl(): ?bool + { + return null; + } + + public function supportedLocalesMeta(): ?array + { + return null; + } +} diff --git a/modules/Cms/Localization/TranslatableCmsLocalizationAdapter.php b/modules/Cms/Localization/TranslatableCmsLocalizationAdapter.php new file mode 100644 index 000000000..2baef4b7a --- /dev/null +++ b/modules/Cms/Localization/TranslatableCmsLocalizationAdapter.php @@ -0,0 +1,131 @@ +<?php + +namespace Modules\Cms\Localization; + +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Contracts\CmsLocalizationContract; +use Modules\Cms\Support\CmsFrontPath; + +/** + * Fallback driver: {@see getLocales()} / {@code config('translatable.locales')} + {@see modularityConfig('cms_routing.*')}. + * Does not require mcamara. + */ +final class TranslatableCmsLocalizationAdapter implements CmsLocalizationContract +{ + public function __construct( + private CanonicalUrlResolverInterface $canonical, + ) {} + + public function driver(): string + { + return 'translatable'; + } + + public function pathSegmentLocales(): array + { + $configured = modularityConfig('cms_routing.path_segment_locales'); + if (is_array($configured) && $configured !== []) { + return $this->sortedLongestFirst(array_values(array_unique(array_filter(array_map('strval', $configured))))); + } + + return $this->sortedLongestFirst(array_values(array_unique(array_map('strval', getLocales())))); + } + + public function supportedLocalesMeta(): array + { + $meta = []; + foreach ($this->pathSegmentLocales() as $locale) { + $meta[$locale] = [ + 'name' => $locale, + 'native' => $locale, + 'script' => 'Latn', + 'regional' => '', + ]; + } + + return $meta; + } + + public function defaultLocale(): string + { + return (string) modularityConfig('cms_routing.default_locale', config('app.locale')); + } + + public function hideDefaultLocaleInUrl(): bool + { + return (bool) modularityConfig('cms_routing.hide_default_locale_segment', false); + } + + public function localizeUrl(?string $url = null, ?string $locale = null): string + { + $locale ??= $this->defaultLocale(); + $path = $this->normalizedRequestPath($url); + [, $inner] = $this->splitLocaleAndInner($path); + + return url(CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath($locale, $inner, $this->canonical)); + } + + public function stripLocaleFromUrl(?string $url = null): string + { + $path = $this->normalizedRequestPath($url); + [, $inner] = $this->splitLocaleAndInner($path); + + return url($inner === '/' ? '/' : $inner); + } + + public function applyLocaleToApplication(string $locale): void + { + app()->setLocale($locale); + } + + private function normalizedRequestPath(?string $url): string + { + if ($url === null || $url === '') { + return $this->canonical->normalizePath(request()->path()); + } + + if (! str_contains($url, '://') && str_starts_with($url, '/')) { + return $this->canonical->normalizePath($url); + } + + $path = parse_url($url, PHP_URL_PATH); + + return $this->canonical->normalizePath(is_string($path) ? $path : '/'); + } + + /** + * @return array{0: string, 1: string} [locale, inner normalized path for registry] + */ + private function splitLocaleAndInner(string $normalizedPath): array + { + $locales = $this->pathSegmentLocales(); + $default = $this->defaultLocale(); + + foreach ($locales as $loc) { + $needle = '/' . trim($loc, '/'); + if ($normalizedPath === $needle) { + return [$loc, '/']; + } + if (str_starts_with($normalizedPath, $needle . '/')) { + $inner = mb_substr($normalizedPath, mb_strlen($needle)); + + return [$loc, $inner === '' || $inner === '/' + ? '/' + : $this->canonical->normalizePath($inner)]; + } + } + + return [$default, $normalizedPath]; + } + + /** + * @param list<string> $locales + * @return list<string> + */ + private function sortedLongestFirst(array $locales): array + { + usort($locales, fn (string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a)); + + return $locales; + } +} diff --git a/modules/Cms/Observers/ParentSegmentUrlRouteObserver.php b/modules/Cms/Observers/ParentSegmentUrlRouteObserver.php new file mode 100644 index 000000000..9372074d6 --- /dev/null +++ b/modules/Cms/Observers/ParentSegmentUrlRouteObserver.php @@ -0,0 +1,167 @@ +<?php + +namespace Modules\Cms\Observers; + +use Modules\Cms\Contracts\PublicUrlRegistryContract; +use Modules\Cms\Entities\ParentSegment; +use WeakMap; + +/** + * Rebuilds {@see \Modules\Cms\Entities\UrlRoute} PAGE_PUBLIC paths for affected models after parent-prefix rows change. + * + * @see \Modules\Cms\Services\CmsParentSegmentResolver + * @see PublicUrlRegistryContract::syncPublicPageRoutesForAllModelsOfClass() + */ +final class ParentSegmentUrlRouteObserver +{ + /** @var list<string> Columns that alter resolved public prefixes or segment priority. */ + private const SYNC_KEYS = ['enabled', 'normalized_prefix', 'locale', 'target_model_class', 'sort_order']; + + /** @var WeakMap<ParentSegment, non-empty-string> */ + private static ?WeakMap $previousTargetsBySegment = null; + + public function __construct( + private PublicUrlRegistryContract $registry, + ) {} + + public function saving(ParentSegment $parentSegment): void + { + $this->stashPreviousTargetFqcnBeforeTargetChange($parentSegment); + } + + public function created(ParentSegment $parentSegment): void + { + if (! $this->resyncEnabled()) { + return; + } + + foreach ($this->uniqueFqdns($this->currentTargetFqdns($parentSegment)) as $fqcn) { + $this->registry->syncPublicPageRoutesForAllModelsOfClass($fqcn); + } + } + + public function updated(ParentSegment $parentSegment): void + { + if (! $this->resyncEnabled()) { + return; + } + + if (! $parentSegment->wasChanged(self::SYNC_KEYS)) { + return; + } + + foreach ($this->uniqueFqdns($this->updateResyncFqdns($parentSegment)) as $fqcn) { + $this->registry->syncPublicPageRoutesForAllModelsOfClass($fqcn); + } + } + + public function deleted(ParentSegment $parentSegment): void + { + if (! $this->resyncEnabled()) { + return; + } + + $fqcn = trim((string) $parentSegment->target_model_class); + if ($fqcn === '') { + return; + } + + $this->registry->syncPublicPageRoutesForAllModelsOfClass($fqcn); + } + + private function stashPreviousTargetFqcnBeforeTargetChange(ParentSegment $parentSegment): void + { + $this->forgetRecordedPreviousTargetFor($parentSegment); + + if (! $this->resyncEnabled() || ! $parentSegment->exists || ! $parentSegment->isDirty('target_model_class')) { + return; + } + + $prev = trim((string) $parentSegment->getOriginal('target_model_class')); + + if ($prev !== '') { + $this->previousTargetsWeakMap()->offsetSet($parentSegment, $prev); + } + } + + /** @param list<string> $fqdns */ + private function previousTargetsWeakMap(): WeakMap + { + return self::$previousTargetsBySegment ??= new WeakMap; + } + + private function forgetRecordedPreviousTargetFor(ParentSegment $parentSegment): void + { + if (self::$previousTargetsBySegment === null) { + return; + } + + if ($this->previousTargetsWeakMap()->offsetExists($parentSegment)) { + $this->previousTargetsWeakMap()->offsetUnset($parentSegment); + } + } + + /** @return list<string> */ + private function consumePreviousTargetFqdnOrEmpty(ParentSegment $parentSegment): array + { + if ( + self::$previousTargetsBySegment === null + || ! $this->previousTargetsWeakMap()->offsetExists($parentSegment) + ) { + return []; + } + + /** @var string $stored */ + $stored = $this->previousTargetsWeakMap()->offsetGet($parentSegment); + $this->previousTargetsWeakMap()->offsetUnset($parentSegment); + + $stored = trim($stored); + + return $stored !== '' ? [$stored] : []; + } + + /** @return list<string> */ + private function currentTargetFqdns(ParentSegment $parentSegment): array + { + $fqcn = trim((string) $parentSegment->target_model_class); + + return $fqcn !== '' ? [$fqcn] : []; + } + + /** @return list<string> */ + private function updateResyncFqdns(ParentSegment $parentSegment): array + { + $current = trim((string) $parentSegment->target_model_class); + /** @var list<string> */ + $targets = []; + + foreach ($this->consumePreviousTargetFqdnOrEmpty($parentSegment) as $prev) { + $targets[] = $prev; + } + + if ($current !== '') { + $targets[] = $current; + } + + return $targets; + } + + /** @param list<string> $list */ + private function uniqueFqdns(array $list): array + { + $seen = []; + foreach ($list as $item) { + $item = trim((string) $item); + if ($item !== '') { + $seen[$item] = true; + } + } + + return array_keys($seen); + } + + private function resyncEnabled(): bool + { + return (bool) modularityConfig('cms_routing.resync_registry_after_parent_segments_change', true); + } +} diff --git a/modules/Cms/Providers/CmsRouteServiceProvider.php b/modules/Cms/Providers/CmsRouteServiceProvider.php new file mode 100644 index 000000000..fb874917c --- /dev/null +++ b/modules/Cms/Providers/CmsRouteServiceProvider.php @@ -0,0 +1,42 @@ +<?php + +namespace Modules\Cms\Providers; + +use Illuminate\Support\Facades\Route; +use Illuminate\Support\ServiceProvider; +use Modules\Cms\Routing\CmsFrontRouteRegistrar; + +/** + * CMS public front routing: {@see Route::cmsPublicFrontRoutes()} macro (legacy inner group) and + * auto-registration via {@see CmsFrontRouteRegistrar::registerAutoForQualifiedModules()}. + */ +class CmsRouteServiceProvider extends ServiceProvider +{ + public function register(): void + { + /** + * Legacy: inner catch-all only; wrap with {@code Route::prefix(...)} if needed. + * Prefer {@see registerAutoForQualifiedModules()} via {@see boot()}. + */ + Route::macro('cmsPublicFrontRoutes', function (): void { + if (! class_exists(CmsFrontRouteRegistrar::class)) { + return; + } + + CmsFrontRouteRegistrar::register(); + }); + } + + public function boot(): void + { + if (! modularityConfig('cms_features.enabled', true)) { + return; + } + + if (! (bool) modularityConfig('cms_routing.auto_register_public_front', true)) { + return; + } + + CmsFrontRouteRegistrar::registerAutoForQualifiedModules(); + } +} diff --git a/modules/Cms/Providers/CmsServiceProvider.php b/modules/Cms/Providers/CmsServiceProvider.php new file mode 100644 index 000000000..b64ca7048 --- /dev/null +++ b/modules/Cms/Providers/CmsServiceProvider.php @@ -0,0 +1,193 @@ +<?php + +namespace Modules\Cms\Providers; + +use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Support\Facades\Route; +use Illuminate\Support\ServiceProvider; +use Modules\Cms\Console\RebuildCmsSitemapCommand; +use Modules\Cms\Observers\ParentSegmentUrlRouteObserver; +use Modules\Cms\Contracts\CanonicalUrlResolverInterface; +use Modules\Cms\Contracts\CmsLocalizationContract; +use Modules\Cms\Contracts\CmsLocalizationOverrideProviderInterface; +use Modules\Cms\Contracts\CmsPromotionScopeApplierInterface; +use Modules\Cms\Contracts\CmsSearchDriverInterface; +use Modules\Cms\Contracts\LeadDeliveryInterface; +use Modules\Cms\Contracts\PublicUrlRegistryContract; +use Modules\Cms\Contracts\RedirectValidationServiceInterface; +use Modules\Cms\Entities\ParentSegment; +use Modules\Cms\Http\Controllers\CmsSignedPublicPreviewController; +use Modules\Cms\Http\Controllers\Front\PublicSitemapController; +use Modules\Cms\Http\Controllers\Front\RobotsTxtController; +use Modules\Cms\Http\Middleware\CanonicalLocaleMiddleware; +use Modules\Cms\Http\Middleware\FallbackLocaleSluglessCanonicalMiddleware; +use Modules\Cms\Http\Middleware\VisitorRedirectMiddleware; +use Modules\Cms\Jobs\ScanCmsPublishWindowBoundariesJob; +use Modules\Cms\Localization\DelegatingCmsLocalizationAdapter; +use Modules\Cms\Localization\McamaraCmsLocalizationAdapter; +use Modules\Cms\Localization\NullCmsLocalizationOverrideProvider; +use Modules\Cms\Localization\TranslatableCmsLocalizationAdapter; +use Modules\Cms\Routing\CmsFrontRouteRegistrar; +use Modules\Cms\Services\CanonicalUrlResolver; +use Modules\Cms\Services\CmsAdminWarnings; +use Modules\Cms\Services\CmsParentSegmentResolver; +use Modules\Cms\Services\CmsPromotionService; +use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Services\CmsSignedPreviewTargetResolver; +use Modules\Cms\Services\CmsSignedPreviewUrlGenerator; +use Modules\Cms\Services\CmsSitemapBuildService; +use Modules\Cms\Services\CmsSitemapCacheService; +use Modules\Cms\Services\CmsSiteSeoSettingsService; +use Modules\Cms\Services\CmsSlugInputValidationService; +use Modules\Cms\Services\CmsUrlRouteRegistry; +use Modules\Cms\Services\CmsVisitorRedirectResolver; +use Modules\Cms\Services\DbFullTextSearchDriver; +use Modules\Cms\Services\DefaultCmsPromotionScopeApplier; +use Modules\Cms\Services\NullLeadDelivery; +use Modules\Cms\Services\RedirectValidationService; +use Unusualify\Modularity\Services\Security\SecurityService; +use Unusualify\Modularity\Services\SlugInputValidationService; + +class CmsServiceProvider extends ServiceProvider +{ + public function register(): void + { + // $this->app->register(CmsRouteServiceProvider::class); + + if (! modularityConfig('cms_features.enabled', true)) { + return; + } + + $this->app->singleton(CanonicalUrlResolverInterface::class, CanonicalUrlResolver::class); + + $this->app->singleton(CmsLocalizationOverrideProviderInterface::class, NullCmsLocalizationOverrideProvider::class); + $this->app->singleton(CmsLocalizationContract::class, function ($app) { + $driver = (string) modularityConfig('cms_routing.localization_driver', 'auto'); + $canonical = $app->make(CanonicalUrlResolverInterface::class); + + $inner = match (true) { + $driver === 'mcamara' => new McamaraCmsLocalizationAdapter($canonical), + $driver === 'translatable' => new TranslatableCmsLocalizationAdapter($canonical), + $driver === 'auto' && class_exists(\Mcamara\LaravelLocalization\Facades\LaravelLocalization::class) => new McamaraCmsLocalizationAdapter($canonical), + default => new TranslatableCmsLocalizationAdapter($canonical), + }; + + return new DelegatingCmsLocalizationAdapter($inner, $app->make(CmsLocalizationOverrideProviderInterface::class)); + }); + + $this->app->singleton(CmsParentSegmentResolver::class); + $this->app->singleton(CmsVisitorRedirectResolver::class); + $this->app->singleton(CmsPublicModelResolver::class); + + $this->app->singleton(CmsUrlRouteRegistry::class); + $this->app->bind(PublicUrlRegistryContract::class, fn ($app) => $app->make(CmsUrlRouteRegistry::class)); + $this->app->singleton(CmsSitemapBuildService::class); + $this->app->singleton(CmsSitemapCacheService::class); + $this->app->singleton(CmsAdminWarnings::class); + $this->app->singleton(CmsSiteSeoSettingsService::class); + $this->app->singleton(SlugInputValidationService::class, CmsSlugInputValidationService::class); + $this->app->singleton(CmsSignedPreviewUrlGenerator::class); + $this->app->singleton(CmsSignedPreviewTargetResolver::class); + + $this->app->singleton(RedirectValidationServiceInterface::class, RedirectValidationService::class); + + if (! modularityConfig('cms_features.register_contracts', true)) { + return; + } + + $this->app->singleton(CmsPromotionScopeApplierInterface::class, DefaultCmsPromotionScopeApplier::class); + $this->app->singleton(CmsPromotionService::class, fn ($app) => new CmsPromotionService( + $app->make(SecurityService::class), + $app->make(CmsPromotionScopeApplierInterface::class), + )); + + $this->app->singleton(LeadDeliveryInterface::class, NullLeadDelivery::class); + $this->app->singleton(CmsSearchDriverInterface::class, DbFullTextSearchDriver::class); + } + + public function boot(): void + { + if (! modularityConfig('cms_features.enabled', true)) { + return; + } + + if (modularityConfig('cms_features.register_commands', true)) { + if ($this->app->runningInConsole()) { + $this->commands([RebuildCmsSitemapCommand::class]); + } + } + + if (modularityConfig('cms_features.register_middlewares', true)) { + Route::aliasMiddleware('modules.cms.canonical.locale', CanonicalLocaleMiddleware::class); + Route::aliasMiddleware('modules.cms.fallback.slugless.canonical', FallbackLocaleSluglessCanonicalMiddleware::class); + Route::aliasMiddleware('modules.cms.visitor.redirect', VisitorRedirectMiddleware::class); + } + + if (modularityConfig('cms_seo.robots.route_enabled', true)) { + Route::middleware('web')->get('/robots.txt', RobotsTxtController::class)->name('cms.robots_txt'); + } + + if ((bool) modularityConfig('cms_sitemap.route_enabled', true)) { + Route::middleware('web')->get('/sitemap.xml', PublicSitemapController::class)->name('cms.sitemap'); + } + + if ((bool) modularityConfig('cms_routing.resync_registry_after_parent_segments_change', true)) { + ParentSegment::observe(ParentSegmentUrlRouteObserver::class); + } + + $this->registerCmsSignedPreviewRoutes(); + $this->registerCmsPublishSchedule(); + } + + private function registerCmsSignedPreviewRoutes(): void + { + if (! modularityConfig('cms_routing.signed_preview.enabled', true)) { + return; + } + + $prefix = trim((string) modularityConfig('cms_routing.signed_preview.path_prefix', 'cms/preview'), '/'); + if ($prefix === '') { + return; + } + + $max = (int) modularityConfig('cms_routing.signed_preview.throttle_max_attempts', 120); + $decay = (int) modularityConfig('cms_routing.signed_preview.throttle_decay_minutes', 1); + $throttle = 'throttle:' . max(1, $max) . ',' . max(1, $decay); + + $definition = static function () use ($prefix, $throttle): void { + Route::middleware(['web', 'signed', $throttle]) + ->get($prefix . '/{module}/{route}/{id}/{locale?}', CmsSignedPublicPreviewController::class) + ->where([ + 'module' => '[A-Za-z][A-Za-z0-9]*', + 'route' => '[A-Za-z][A-Za-z0-9]*', + 'id' => '[0-9]+', + ]) + ->name('cms.signed_preview.show'); + }; + + $domain = CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain(); + if ($domain !== null && $domain !== '') { + Route::domain($domain)->group($definition); + } else { + $definition(); + } + } + + private function registerCmsPublishSchedule(): void + { + if (! modularityConfig('cms_schedule.register_with_laravel_schedule', false)) { + return; + } + + $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void { + $job = $schedule->job(new ScanCmsPublishWindowBoundariesJob); + $frequency = (string) modularityConfig('cms_schedule.frequency', 'everyFiveMinutes'); + + match ($frequency) { + 'everyMinute' => $job->everyMinute(), + 'hourly' => $job->hourly(), + default => $job->everyFiveMinutes(), + }; + }); + } +} diff --git a/modules/Cms/Repositories/.gitkeep b/modules/Cms/Repositories/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Repositories/HomepageTestRepository.php b/modules/Cms/Repositories/HomepageTestRepository.php new file mode 100644 index 000000000..1e573964c --- /dev/null +++ b/modules/Cms/Repositories/HomepageTestRepository.php @@ -0,0 +1,23 @@ +<?php + +namespace Modules\Cms\Repositories; + +use Modules\Cms\Entities\HomepageTest; +use Modules\Cms\Repositories\Traits\CmrTrait; +use Unusualify\Modularity\Repositories\Repository; +use Unusualify\Modularity\Repositories\Traits\FilepondsTrait; +use Unusualify\Modularity\Repositories\Traits\PublishableTrait; +use Unusualify\Modularity\Repositories\Traits\TranslatableMetadataTrait; + +class HomepageTestRepository extends Repository +{ + use FilepondsTrait, + CmrTrait, + TranslatableMetadataTrait, + PublishableTrait; + + public function __construct(HomepageTest $model) + { + $this->model = $model; + } +} diff --git a/modules/Cms/Repositories/PageRepository.php b/modules/Cms/Repositories/PageRepository.php new file mode 100644 index 000000000..4382a6585 --- /dev/null +++ b/modules/Cms/Repositories/PageRepository.php @@ -0,0 +1,39 @@ +<?php + +namespace Modules\Cms\Repositories; + +use Modules\Cms\Entities\Page; +use Modules\Cms\Repositories\Traits\CmrTrait; +use Unusualify\Modularity\Repositories\Repository; +use Unusualify\Modularity\Repositories\Traits\FilepondsTrait; +use Unusualify\Modularity\Repositories\Traits\FilesTrait; +use Unusualify\Modularity\Repositories\Traits\ImagesTrait; +use Unusualify\Modularity\Repositories\Traits\PublishableTrait; +use Unusualify\Modularity\Repositories\Traits\RepeatersTrait; +use Unusualify\Modularity\Repositories\Traits\RevisionsTrait; +use Unusualify\Modularity\Repositories\Traits\SlugsTrait; +use Unusualify\Modularity\Repositories\Traits\TranslatableMetadataTrait; +use Unusualify\Modularity\Repositories\Traits\TranslationsTrait; + +class PageRepository extends Repository +{ + /** + * TranslationsTrait must run before SlugsTrait so getFormFieldsTranslationsTrait does not wipe SlugsTrait output. + * TranslatableMetadataTrait must come after TranslationsTrait (form pipeline + guards). + */ + use TranslationsTrait, + TranslatableMetadataTrait, + RevisionsTrait, + SlugsTrait, + RepeatersTrait, + FilesTrait, + ImagesTrait, + FilepondsTrait, + CmrTrait, + PublishableTrait; + + public function __construct(Page $model) + { + $this->model = $model; + } +} diff --git a/modules/Cms/Repositories/ParentSegmentRepository.php b/modules/Cms/Repositories/ParentSegmentRepository.php new file mode 100644 index 000000000..d58277c23 --- /dev/null +++ b/modules/Cms/Repositories/ParentSegmentRepository.php @@ -0,0 +1,71 @@ +<?php + +namespace Modules\Cms\Repositories; + +use Illuminate\Validation\ValidationException; +use Modules\Cms\Entities\ParentSegment; +use Modules\Cms\Support\ParentSegmentBindingValidator; +use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Repositories\Repository; + +class ParentSegmentRepository extends Repository +{ + public function __construct(ParentSegment $model) + { + $this->model = $model; + } + + /** + * Ensure {@code target_model_class} is a registered module route model (FQCN from the select). + * + * @param \Illuminate\Database\Eloquent\Model $object + * @param array<string, mixed> $fields + * @return array<string, mixed> + */ + public function prepareFieldsBeforeSave($object, $fields) + { + if (array_key_exists('normalized_prefix', $fields)) { + $fields['normalized_prefix'] = trim((string) ($fields['normalized_prefix'] ?? '')); + } + + if (! empty($fields['target_model_class'])) { + if (Modularity::resolveTargetModuleRouteForModelClass((string) $fields['target_model_class']) === null) { + throw ValidationException::withMessages([ + 'target_model_class' => [__('Unknown model class for a module route.')], + ]); + } + $fields['target_model_class'] = (string) $fields['target_model_class']; + } + + /** @var ParentSegment $object */ + $effectiveTargetClass = isset($fields['target_model_class']) + ? trim((string) $fields['target_model_class']) + : ($object->exists ? trim((string) $object->target_model_class) : ''); + + if ($effectiveTargetClass === '') { + return parent::prepareFieldsBeforeSave($object, $fields); + } + + $effectiveLocale = trim((string) (array_key_exists('locale', $fields) + ? ($fields['locale'] ?? '') + : ($object->exists ? (string) ($object->locale ?? '') : ''))); + + $effectivePrefix = array_key_exists('normalized_prefix', $fields) + ? (string) $fields['normalized_prefix'] + : ($object->exists ? trim((string) ($object->normalized_prefix ?? '')) : ''); + + $effectiveEnabled = array_key_exists('enabled', $fields) + ? (bool) $fields['enabled'] + : ($object->exists ? (bool) $object->enabled : true); + + ParentSegmentBindingValidator::assertExclusiveEmptyPrefixAcrossTargetsIfEnabled( + $effectiveEnabled, + $effectiveTargetClass, + $effectiveLocale, + $effectivePrefix, + $object->exists ? $object->getKey() : null, + ); + + return parent::prepareFieldsBeforeSave($object, $fields); + } +} diff --git a/modules/Cms/Repositories/RedirectRepository.php b/modules/Cms/Repositories/RedirectRepository.php new file mode 100644 index 000000000..12e8cdb24 --- /dev/null +++ b/modules/Cms/Repositories/RedirectRepository.php @@ -0,0 +1,17 @@ +<?php + +namespace Modules\Cms\Repositories; + +use Modules\Cms\Entities\Redirect; +use Modules\Cms\Repositories\Traits\CmsRedirectUrlRouteRegistryTrait; +use Unusualify\Modularity\Repositories\Repository; + +class RedirectRepository extends Repository +{ + use CmsRedirectUrlRouteRegistryTrait; + + public function __construct(Redirect $model) + { + $this->model = $model; + } +} diff --git a/modules/Cms/Repositories/SiteSettingRepository.php b/modules/Cms/Repositories/SiteSettingRepository.php new file mode 100644 index 000000000..a114f151e --- /dev/null +++ b/modules/Cms/Repositories/SiteSettingRepository.php @@ -0,0 +1,58 @@ +<?php + +namespace Modules\Cms\Repositories; + +use Modules\Cms\Entities\SiteSetting; +use Unusualify\Modularity\Repositories\Repository; + +class SiteSettingRepository extends Repository +{ + public function __construct(SiteSetting $model) + { + $this->model = $model; + } + + /** + * Single site-setting row for the given composite key (includes soft-deleted for restore semantics). + */ + public function findScoped(string $groupKey, string $key, string $locale): ?SiteSetting + { + return $this->model->newQuery() + ->withTrashed() + ->where('group_key', $groupKey) + ->where('key', $key) + ->where('locale', $locale) + ->first(); + } + + /** + * Persist a value or remove the row when {@code $value} is null or whitespace-only (revert to env/config fallback). + */ + public function putScoped(string $groupKey, string $key, string $locale, ?string $value): void + { + if ($value === null || trim($value) === '') { + $existing = $this->findScoped($groupKey, $key, $locale); + if ($existing !== null) { + $existing->forceDelete(); + } + + return; + } + + $model = $this->findScoped($groupKey, $key, $locale) ?? $this->model->newInstance([ + 'group_key' => $groupKey, + 'key' => $key, + 'locale' => $locale, + ]); + + if ($model->trashed()) { + $model->restore(); + } + + $model->fill([ + 'value' => $value, + 'is_active' => true, + ]); + $model->save(); + } +} diff --git a/modules/Cms/Repositories/SitemapRepository.php b/modules/Cms/Repositories/SitemapRepository.php new file mode 100644 index 000000000..c18109c51 --- /dev/null +++ b/modules/Cms/Repositories/SitemapRepository.php @@ -0,0 +1,106 @@ +<?php + +namespace Modules\Cms\Repositories; + +use Illuminate\Database\Eloquent\Model; +use Modules\Cms\Entities\CmsSitemapableItem; +use Modules\Cms\Entities\Sitemap; +use Modules\Cms\Services\CmsSitemapBuildService; +use Modules\Cms\Services\CmsSitemapCacheService; +use Unusualify\Modularity\Repositories\Repository; + +/** + * CMS sitemap: panel dry-run / commit, backed by {@see CmsSitemapBuildService} + {@see CmsSitemapCacheService}. + * {@see Sitemap} row(s) in DB hold optional per-urlable override rows ({@see \Modules\Cms\Entities\CmsSitemapableItem}). + */ +class SitemapRepository extends Repository +{ + public function __construct( + Sitemap $model, + protected CmsSitemapBuildService $sitemapBuild, + protected CmsSitemapCacheService $sitemapCache, + ) { + $this->model = $model; + } + + /** + * @return array{ok: true, xml: string, urlCount: int, bytes: int} + */ + public function getPanelDryRunPayload(): array + { + $xml = $this->sitemapBuild->buildXml(); + $dtos = $this->sitemapBuild->buildEntryDtos(); + $urlCount = is_countable($dtos) ? count($dtos) : 0; + + return [ + 'ok' => true, + 'xml' => $xml, + 'urlCount' => $urlCount, + 'bytes' => mb_strlen($xml), + ]; + } + + /** + * @return array{ok: true, message: string, xml: string, urlCount: int, bytes: int} + */ + public function commitSitemapToLiveCache(): array + { + $xml = $this->sitemapBuild->buildXml(); + $this->sitemapCache->commit($xml); + $dtos = $this->sitemapBuild->buildEntryDtos(); + $urlCount = is_countable($dtos) ? count($dtos) : 0; + + return [ + 'ok' => true, + 'message' => __('Sitemap cache updated.'), + 'xml' => $xml, + 'urlCount' => $urlCount, + 'bytes' => mb_strlen($xml), + ]; + } + + /** + * @return list<array<string, mixed>> + */ + public function getSitemapItemRowsForPanel(): array + { + return $this->sitemapBuild->getPanelItemRows(); + } + + /** + * Create or update a per–urlable override for the default sitemap bucket. + * + * @return array{ok: true, item: array{id: int, changefreq: string, priority: string}} + */ + public function upsertSitemapableItem(string $sitemapableType, int $sitemapableId, string $changefreq, string $priority): array + { + if (! is_a($sitemapableType, Model::class, true)) { + abort(422, 'Invalid sitemapable type.'); + } + + $sitemapId = (int) modularityConfig('cms_sitemap.default_sitemap_id', 1); + + $row = CmsSitemapableItem::query()->updateOrCreate( + [ + 'sitemap_id' => $sitemapId, + 'sitemapable_type' => $sitemapableType, + 'sitemapable_id' => $sitemapableId, + ], + [ + 'changefreq' => $changefreq, + 'priority' => (float) $priority, + ], + ); + + $p = $row->priority !== null ? number_format((float) $row->priority, 1, '.', '') : (string) $priority; + + return [ + 'ok' => true, + 'item' => [ + 'id' => (int) $row->getKey(), + 'changefreq' => (string) $row->changefreq, + 'priority' => $p, + ], + ]; + } +} diff --git a/modules/Cms/Repositories/Traits/CmrTrait.php b/modules/Cms/Repositories/Traits/CmrTrait.php new file mode 100644 index 000000000..8763c5144 --- /dev/null +++ b/modules/Cms/Repositories/Traits/CmrTrait.php @@ -0,0 +1,14 @@ +<?php + +namespace Modules\Cms\Repositories\Traits; + +/** + * Content module route (CMR): parent-segment repository behaviour + {@see UrlRoute} registry sync for panel routes. + * + * For {@see \Modules\Cms\Entities\Page}, {@see \Modules\Cms\Entities\HomepageTest}, and similar CMS module routes. + */ +trait CmrTrait +{ + use ParentSegmentTrait, + UrlRouteRegistrySyncTrait; +} diff --git a/modules/Cms/Repositories/Traits/CmsRedirectUrlRouteRegistryTrait.php b/modules/Cms/Repositories/Traits/CmsRedirectUrlRouteRegistryTrait.php new file mode 100644 index 000000000..fe26331e0 --- /dev/null +++ b/modules/Cms/Repositories/Traits/CmsRedirectUrlRouteRegistryTrait.php @@ -0,0 +1,73 @@ +<?php + +namespace Modules\Cms\Repositories\Traits; + +use Illuminate\Database\Eloquent\Model; +use Modules\Cms\Services\CmsUrlRouteRegistry; + +/** + * Registers redirect {@see \Modules\Cms\Entities\UrlRoute} source rows for the repository's own {@see Redirect} model. + * Use on {@see \Modules\Cms\Repositories\RedirectRepository} only — separate from {@see UrlRouteRegistrySyncTrait}. + */ +trait CmsRedirectUrlRouteRegistryTrait +{ + use PublicUrlRegistrySyncDispatchTrait; + + public function afterSaveCmsRedirectUrlRouteRegistryTrait($object, $fields): void + { + $this->afterSavePublicUrlRegistrySyncTrait($object, $fields); + } + + public function afterDeleteCmsRedirectUrlRouteRegistryTrait($object): void + { + $this->afterDeletePublicUrlRegistrySyncTrait($object); + } + + public function afterRestoreCmsRedirectUrlRouteRegistryTrait($object): void + { + $this->afterRestorePublicUrlRegistrySyncTrait($object); + } + + /** + * @return class-string<Model> + */ + protected function cmsRedirectUrlRouteRegistryModelClass(): string + { + /** @var Model $m */ + $m = $this->model; + + return $m::class; + } + + protected function publicUrlRegistryAfterSaveHandlers(): array + { + $class = $this->cmsRedirectUrlRouteRegistryModelClass(); + + return [ + $class => function (object $object, array $fields) use ($class): void { + if (! $object instanceof $class) { + return; + } + + /** @var Model $object */ + app(CmsUrlRouteRegistry::class)->syncRedirectSourceRoute($object); + }, + ]; + } + + protected function publicUrlRegistryAfterDeleteHandlers(): array + { + $class = $this->cmsRedirectUrlRouteRegistryModelClass(); + + return [ + $class => function (object $object) use ($class): void { + if (! $object instanceof $class) { + return; + } + + /** @var Model $object */ + app(CmsUrlRouteRegistry::class)->removeRedirectSourceRoute($object); + }, + ]; + } +} diff --git a/modules/Cms/Repositories/Traits/ParentSegmentTrait.php b/modules/Cms/Repositories/Traits/ParentSegmentTrait.php new file mode 100644 index 000000000..3f4f7ef2d --- /dev/null +++ b/modules/Cms/Repositories/Traits/ParentSegmentTrait.php @@ -0,0 +1,61 @@ +<?php + +namespace Modules\Cms\Repositories\Traits; + + +/** + * CMS: repositories whose {@see \Unusualify\Modularity\Repositories\Repository::getModel()} uses + * {@see \Unusualify\Modularity\Entities\Traits\IsCmr} (content module route) and thus {@see HasParentSegment}. + * + * Enables parent-segment-aware tooling (hydrate selects, slug validation hooks) without hard-coding model classes. + * Optional {@see $cmsAdminWarningsBuffer} / {@see pullCmsAdminWarnings()} support non-blocking panel hints after save + * (e.g. {@see UrlRouteRegistrySyncTrait} stages via {@see setCmsAdminWarningsBuffer()}). + */ +trait ParentSegmentTrait +{ + /** + * Staged non-blocking hints for the next panel JSON / redirect flash (path overlap, SEO soft checks, etc.). + * + * @var list<string>|null + */ + protected ?array $cmsAdminWarningsBuffer = null; + + /** + * Model FQCN managed by this repository. + * + * @return class-string<\Illuminate\Database\Eloquent\Model> + */ + public function parentSegmentTargetModelClass(): string + { + return get_class($this->getModel()); + } + + /** + * Whether the bound model opts into parent-segment bindings / URL integration. + */ + public function usesParentSegmentForUrl(): bool + { + return classHasTrait($this->parentSegmentTargetModelClass(), \Modules\Cms\Entities\Concerns\HasParentSegment::class); + } + + /** + * Consumes and clears any staged admin warnings from the last save (empty when none). + * + * @return list<string> + */ + public function pullCmsAdminWarnings(): array + { + $warnings = $this->cmsAdminWarningsBuffer ?? []; + $this->cmsAdminWarningsBuffer = null; + + return array_values($warnings); + } + + /** + * @param list<string> $warnings + */ + protected function setCmsAdminWarningsBuffer(array $warnings): void + { + $this->cmsAdminWarningsBuffer = array_values($warnings); + } +} diff --git a/modules/Cms/Repositories/Traits/PublicUrlRegistrySyncDispatchTrait.php b/modules/Cms/Repositories/Traits/PublicUrlRegistrySyncDispatchTrait.php new file mode 100644 index 000000000..cb085493c --- /dev/null +++ b/modules/Cms/Repositories/Traits/PublicUrlRegistrySyncDispatchTrait.php @@ -0,0 +1,53 @@ +<?php + +namespace Modules\Cms\Repositories\Traits; + +/** + * Dispatches {@code afterSave} / {@code afterDelete} / {@code afterRestore} to callables keyed by model class. + * Used by CMS repositories whose entities sync into {@see \Modules\Cms\Contracts\PublicUrlRegistryContract} (typically {@see \Modules\Cms\Entities\UrlRoute} rows). + * + * Implement {@see publicUrlRegistryAfterSaveHandlers()} / {@see publicUrlRegistryAfterDeleteHandlers()} in the repository trait that uses this. + */ +trait PublicUrlRegistrySyncDispatchTrait +{ + public function afterSavePublicUrlRegistrySyncTrait(object $object, array $fields): void + { + if (property_exists($this, 'passAfterSaveSlugsTrait') && $this->passAfterSaveSlugsTrait === true) { + return; + } + + foreach ($this->publicUrlRegistryAfterSaveHandlers() as $class => $handler) { + if ($object instanceof $class) { + $handler($object, $fields); + + return; + } + } + } + + public function afterDeletePublicUrlRegistrySyncTrait(object $object): void + { + foreach ($this->publicUrlRegistryAfterDeleteHandlers() as $class => $handler) { + if ($object instanceof $class) { + $handler($object); + + return; + } + } + } + + public function afterRestorePublicUrlRegistrySyncTrait(object $object): void + { + $this->afterSavePublicUrlRegistrySyncTrait($object, []); + } + + /** + * @return array<class-string, callable(object, array):void> + */ + abstract protected function publicUrlRegistryAfterSaveHandlers(): array; + + /** + * @return array<class-string, callable(object):void> + */ + abstract protected function publicUrlRegistryAfterDeleteHandlers(): array; +} diff --git a/modules/Cms/Repositories/Traits/UrlRouteRegistrySyncTrait.php b/modules/Cms/Repositories/Traits/UrlRouteRegistrySyncTrait.php new file mode 100644 index 000000000..1ecdc2fd7 --- /dev/null +++ b/modules/Cms/Repositories/Traits/UrlRouteRegistrySyncTrait.php @@ -0,0 +1,80 @@ +<?php + +namespace Modules\Cms\Repositories\Traits; + +use Illuminate\Database\Eloquent\Model; +use Modules\Cms\Services\CmsAdminWarnings; +use Modules\Cms\Services\CmsUrlRouteRegistry; + +/** + * Keeps {@see \Modules\Cms\Entities\UrlRoute} in sync for the **repository's own model** (any page-like entity with slugs). + * Composes {@see PublicUrlRegistrySyncDispatchTrait}; handlers key off {@see $this->model}'s class — no per-entity static maps. + * + * Basename sorts after {@see \Unusualify\Modularity\Repositories\Traits\TranslationsTrait} so slug + translation rows are persisted first. + */ +trait UrlRouteRegistrySyncTrait +{ + use PublicUrlRegistrySyncDispatchTrait; + + public function afterSaveUrlRouteRegistrySyncTrait($object, $fields): void + { + $this->afterSavePublicUrlRegistrySyncTrait($object, $fields); + } + + public function afterDeleteUrlRouteRegistrySyncTrait($object): void + { + $this->afterDeletePublicUrlRegistrySyncTrait($object); + } + + public function afterRestoreUrlRouteRegistrySyncTrait($object): void + { + $this->afterRestorePublicUrlRegistrySyncTrait($object); + } + + /** + * @return class-string<Model> + */ + protected function urlRouteRegistryRepositoryModelClass(): string + { + /** @var Model $m */ + $m = $this->model; + + return $m::class; + } + + protected function publicUrlRegistryAfterSaveHandlers(): array + { + $class = $this->urlRouteRegistryRepositoryModelClass(); + + return [ + $class => function (object $object, array $fields) use ($class): void { + if (! $object instanceof $class) { + return; + } + + /** @var Model $object */ + app(CmsUrlRouteRegistry::class)->syncPublicPageRoutesForModel($object); + + if (method_exists($this, 'setCmsAdminWarningsBuffer')) { + $this->setCmsAdminWarningsBuffer(app(CmsAdminWarnings::class)->gather($object)); + } + }, + ]; + } + + protected function publicUrlRegistryAfterDeleteHandlers(): array + { + $class = $this->urlRouteRegistryRepositoryModelClass(); + + return [ + $class => function (object $object) use ($class): void { + if (! $object instanceof $class) { + return; + } + + /** @var Model $object */ + app(CmsUrlRouteRegistry::class)->removePublicPageRoutesForModel($object); + }, + ]; + } +} diff --git a/modules/Cms/Resources/assets/.gitkeep b/modules/Cms/Resources/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Resources/assets/Pages/.gitkeep b/modules/Cms/Resources/assets/Pages/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Resources/assets/Pages/Sitemap/Index.vue b/modules/Cms/Resources/assets/Pages/Sitemap/Index.vue new file mode 100644 index 000000000..792f40c97 --- /dev/null +++ b/modules/Cms/Resources/assets/Pages/Sitemap/Index.vue @@ -0,0 +1,336 @@ +<template> + <div class="pa-4 ue-cms-sitemap-index"> + <v-breadcrumbs + v-if="breadcrumbItems.length" + class="px-0 pt-0 pb-2" + density="compact" + :items="breadcrumbItems" + > + <template #title="{ item }"> + <v-breadcrumbs-item + :disabled="!!item.disabled" + :class="{ 'text-primary cursor-pointer': isBreadcrumbClickable(item) }" + @click="onBreadcrumbClick(item, $event)" + > + {{ item.title }} + </v-breadcrumbs-item> + </template> + </v-breadcrumbs> + + <v-alert + v-if="publicSitemapUrl" + type="info" + variant="tonal" + class="mb-4" + border="start" + density="compact" + > + <a + :href="publicSitemapUrl" + target="_blank" + rel="noopener noreferrer" + >{{ publicSitemapUrl }}</a> + </v-alert> + + <v-alert + type="info" + variant="tonal" + class="mb-4" + border="start" + > + {{ introText }} + </v-alert> + + <div class="d-flex flex-wrap ga-2 mb-6"> + <v-btn + color="primary" + :loading="loadingDry" + :disabled="!sitemapEndpoints.dryRun" + @click="runDryRun" + > + {{ t('modules.cms.sitemap.dry_run', 'Dry-run (preview XML)') }} + </v-btn> + <v-btn + color="warning" + :loading="loadingCommit" + :disabled="!sitemapEndpoints.commit" + @click="confirmCommit = true" + > + {{ t('modules.cms.sitemap.commit', 'Commit to live cache') }} + </v-btn> + </div> + + <v-data-table + :items="rowModels" + :headers="dataHeaders" + density="compact" + :items-per-page="50" + > + <template #item.changefreq="{ item }"> + <v-select + v-model="item.draftChangefreq" + :items="changefreqItems" + density="compact" + hide-details + variant="outlined" + class="sitemap-cf" + /> + </template> + <template #item.priority="{ item }"> + <v-text-field + v-model="item.draftPriority" + type="number" + step="0.1" + min="0" + max="1" + density="compact" + hide-details + variant="outlined" + class="sitemap-prio" + /> + </template> + <template #item.save="{ item }"> + <v-btn + size="small" + color="primary" + variant="tonal" + :loading="item.saving" + :disabled="!sitemapEndpoints.itemUpsert" + @click="saveRow(item)" + > + {{ t('modules.cms.sitemap.save_row', 'Save') }} + </v-btn> + </template> + </v-data-table> + + <v-card + v-if="lastDryPayload" + class="mt-6" + > + <v-card-title class="text-subtitle-1"> + {{ t('modules.cms.sitemap.last_dry', 'Last dry-run') }} + </v-card-title> + <v-card-text> + <div class="text-body-2 mb-2"> + {{ t('modules.cms.sitemap.url_count', 'URL count') }}: {{ lastDryPayload.urlCount }} + · + {{ t('modules.cms.sitemap.bytes', 'Bytes') }}: {{ lastDryPayload.bytes }} + </div> + <pre + class="text-caption overflow-auto sitemap-xml-preview" + >{{ lastDryPayload.xml }}</pre> + </v-card-text> + </v-card> + + <v-dialog + v-model="confirmCommit" + max-width="480" + > + <v-card> + <v-card-title> + {{ t('modules.cms.sitemap.commit_confirm', 'Write sitemap to live cache?') }} + </v-card-title> + <v-card-text> + {{ t('modules.cms.sitemap.commit_hint', 'This updates the public sitemap.xml served from cache.') }} + </v-card-text> + <v-card-actions> + <v-spacer /> + <v-btn + variant="text" + @click="confirmCommit = false" + > + {{ te('messages.cancel') ? t('messages.cancel') : 'Cancel' }} + </v-btn> + <v-btn + color="warning" + :loading="loadingCommit" + @click="runCommit" + > + {{ te('messages.confirm') ? t('messages.confirm') : 'Confirm' }} + </v-btn> + </v-card-actions> + </v-card> + </v-dialog> + </div> +</template> + +<script setup> + import { computed, ref, watch } from 'vue' + import { router, usePage } from '@inertiajs/vue3' + import { useI18n } from 'vue-i18n' + import MainLayout from '@/Pages/Layouts/MainLayout.vue' + import { useAlert, useStepUpAwareJsonPost } from '@/hooks' + + const props = defineProps({ + tableAttributes: { type: Object, default: () => ({}) }, + endpoints: { type: Object, default: () => ({}) }, + }) + + const { t, te } = useI18n({ useScope: 'global' }) + const { openAlert } = useAlert() + const { postJson } = useStepUpAwareJsonPost() + const inertiaPage = usePage() + + const sitemapPanel = computed(() => props.tableAttributes?.sitemapPanel || {}) + const publicSitemapUrl = computed(() => sitemapPanel.value?.publicSitemapUrl || null) + const introText = t( + 'modules.cms.sitemap.intro', + 'Table lists every URL line that will appear in the sitemap. Edit changefreq and priority per public page, then use dry-run to preview XML and commit to publish.', + ) + + const sitemapEndpoints = computed(() => { + const e = props.endpoints || {} + return { + dryRun: e.sitemapDryRun, + commit: e.sitemapCommit, + itemUpsert: e.sitemapItemUpsert, + } + }) + + const changefreqItems = [ + 'always', + 'hourly', + 'daily', + 'weekly', + 'monthly', + 'yearly', + 'never', + ] + + const dataHeaders = [ + { title: '#', key: 'sort', width: '64px' }, + { title: t('modules.cms.sitemap.col_locale', 'Locale'), key: 'locale' }, + { title: t('modules.cms.sitemap.col_path', 'Path'), key: 'normalized_path' }, + { title: t('modules.cms.sitemap.col_url', 'URL'), key: 'loc' }, + { title: t('modules.cms.sitemap.col_lastmod', 'Last mod'), key: 'lastmod' }, + { title: t('modules.cms.sitemap.changefreq', 'Change frequency'), key: 'changefreq' }, + { title: t('modules.cms.sitemap.priority', 'Priority'), key: 'priority' }, + { title: '', key: 'save', sortable: false, width: '100px' }, + ] + + const rowModels = ref([]) + + watch( + () => sitemapPanel.value?.rows, + (rows) => { + if (!Array.isArray(rows)) { + rowModels.value = [] + return + } + rowModels.value = rows.map((r) => ({ + ...r, + draftChangefreq: r.changefreq, + draftPriority: String(r.priority), + saving: false, + })) + }, + { immediate: true, deep: true } + ) + + const breadcrumbItems = computed(() => { + const raw = inertiaPage.props?.tableAttributes?.breadcrumbs + return Array.isArray(raw) ? raw : [] + }) + + function isBreadcrumbClickable (item) { + return Boolean(item?.href) && !item?.disabled + } + + function onBreadcrumbClick (item, e) { + if (!isBreadcrumbClickable(item)) { + return + } + e?.preventDefault() + router.visit(item.href, { preserveScroll: true }) + } + + const loadingDry = ref(false) + const loadingCommit = ref(false) + const lastDryPayload = ref(null) + const confirmCommit = ref(false) + + async function runDryRun () { + const url = sitemapEndpoints.value.dryRun + if (!url) { return } + loadingDry.value = true + lastDryPayload.value = null + try { + const { data } = await postJson(url, {}) + lastDryPayload.value = data + openAlert({ + message: t('modules.cms.sitemap.dry_ok', 'Dry-run complete.'), + variant: 'success', + }) + } catch (e) { + const msg = e.response?.data?.message ?? e.message ?? t('modules.cms.sitemap.request_failed', 'Request failed') + openAlert({ message: String(msg), variant: 'error' }) + } finally { + loadingDry.value = false + } + } + + async function runCommit () { + const url = sitemapEndpoints.value.commit + if (!url) { return } + loadingCommit.value = true + try { + const { data } = await postJson(url, {}) + confirmCommit.value = false + openAlert({ + message: data?.message || t('modules.cms.sitemap.commit_ok', 'Sitemap cache updated.'), + variant: 'success', + }) + router.reload({ only: ['tableAttributes', 'endpoints'] }) + } catch (e) { + const msg = e.response?.data?.message ?? e.message ?? t('modules.cms.sitemap.request_failed', 'Request failed') + openAlert({ message: String(msg), variant: 'error' }) + } finally { + loadingCommit.value = false + } + } + + async function saveRow (item) { + const url = sitemapEndpoints.value.itemUpsert + if (!url) { return } + const prio = parseFloat(String(item.draftPriority).replace(',', '.')) + if (Number.isNaN(prio) || prio < 0 || prio > 1) { + openAlert({ message: t('modules.cms.sitemap.bad_priority', 'Priority must be between 0 and 1.'), variant: 'error' }) + return + } + item.saving = true + try { + const { data } = await postJson(url, { + sitemapable_type: item.urlable_type, + sitemapable_id: item.urlable_id, + changefreq: item.draftChangefreq, + priority: String(prio), + }) + if (data?.ok && data?.item) { + item.sitemapable_item_id = data.item.id + item.changefreq = data.item.changefreq + item.priority = data.item.priority + item.draftChangefreq = data.item.changefreq + item.draftPriority = data.item.priority + } + openAlert({ message: t('modules.cms.sitemap.save_ok', 'Saved.'), variant: 'success' }) + } catch (e) { + const msg = e.response?.data?.message ?? (e.response?.data?.errors + ? Object.values(e.response.data.errors).flat().join(' ') + : e.message) ?? t('modules.cms.sitemap.request_failed', 'Request failed') + openAlert({ message: String(msg), variant: 'error' }) + } finally { + item.saving = false + } + } + + defineOptions({ + name: 'CmsSitemapIndex', + layout: (h, page) => h(MainLayout, () => page), + }) +</script> + +<style scoped> +.sitemap-cf { min-width: 150px; max-width: 200px; } +.sitemap-prio { max-width: 110px; } +.sitemap-xml-preview { max-height: 280px; } +</style> diff --git a/modules/Cms/Resources/lang/.gitkeep b/modules/Cms/Resources/lang/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Resources/views/.keep b/modules/Cms/Resources/views/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Resources/views/components/responsive-title.blade.php b/modules/Cms/Resources/views/components/responsive-title.blade.php new file mode 100644 index 000000000..049d069dc --- /dev/null +++ b/modules/Cms/Resources/views/components/responsive-title.blade.php @@ -0,0 +1,59 @@ +{{-- + Title shown inside display-threshold blocks: only one block is visible per viewport width. + Pass: $title (string|null) +--}} +@php + $label = $title ?? 'Page'; +@endphp + +<div class="card border-primary shadow-sm mb-4"> + <div class="card-header bg-primary text-white small fw-semibold"> + Title by Bootstrap display breakpoint (resize to see one tier at a time) + </div> + <div class="card-body"> + <div class="border rounded p-3 mb-3 d-block d-sm-none"> + <span class="badge text-bg-secondary mb-2">xs · <576px · d-block d-sm-none</span> + <p class="fs-6 fw-normal mb-0 text-break">{{ $label }}</p> + </div> + <div class="border rounded p-3 mb-3 d-none d-sm-block d-md-none"> + <span class="badge text-bg-secondary mb-2">sm · ≥576px · d-none d-sm-block d-md-none</span> + <p class="h6 mb-0 text-break">{{ $label }}</p> + </div> + <div class="border rounded p-3 mb-3 d-none d-md-block d-lg-none"> + <span class="badge text-bg-secondary mb-2">md · ≥768px · d-none d-md-block d-lg-none</span> + <p class="h5 mb-0 text-break">{{ $label }}</p> + </div> + <div class="border rounded p-3 mb-3 d-none d-lg-block d-xl-none"> + <span class="badge text-bg-secondary mb-2">lg · ≥992px · d-none d-lg-block d-xl-none</span> + <p class="h4 mb-0 text-break">{{ $label }}</p> + </div> + <div class="border rounded p-3 mb-3 d-none d-xl-block d-xxl-none"> + <span class="badge text-bg-secondary mb-2">xl · ≥1200px · d-none d-xl-block d-xxl-none</span> + <p class="h3 mb-0 text-break">{{ $label }}</p> + </div> + <div class="border rounded p-3 mb-0 d-none d-xxl-block"> + <span class="badge text-bg-secondary mb-2">xxl · ≥1400px · d-none d-xxl-block</span> + <p class="display-6 mb-0 text-break">{{ $label }}</p> + </div> + </div> +</div> + +<div class="card border-info shadow-sm mb-4"> + <div class="card-header bg-info text-dark small fw-semibold"> + Same title in coarse buckets (mobile / tablet / desktop) + </div> + <div class="card-body"> + <div class="d-block d-md-none"> + <span class="badge text-bg-primary mb-2">mobile · d-block d-md-none</span> + <p class="lead mb-0 text-break">{{ $label }}</p> + </div> + <div class="d-none d-md-block d-lg-none"> + <span class="badge text-bg-warning text-dark mb-2">tablet · d-none d-md-block d-lg-none</span> + <p class="h4 mb-0 text-break">{{ $label }}</p> + </div> + <div class="d-none d-lg-block"> + <span class="badge text-bg-success mb-2">desktop · d-none d-lg-block</span> + <p class="display-6 mb-0 text-break">{{ $label }}</p> + </div> + </div> +</div> diff --git a/modules/Cms/Resources/views/homepage_test/custom.blade.php b/modules/Cms/Resources/views/homepage_test/custom.blade.php new file mode 100644 index 000000000..eb64e92a5 --- /dev/null +++ b/modules/Cms/Resources/views/homepage_test/custom.blade.php @@ -0,0 +1,49 @@ +<!DOCTYPE html> +<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{ $seoTitle ?? ('Preview — ' . ($item->title ?? 'Homepage Test')) }} + @if(! empty($seoDescription)) + + @endif + @if(! empty($canonicalUrl)) + + @endif + @if(! empty($robotsMeta)) + + @endif + + + + @php + $locale = app()->getLocale(); + $displayName = data_get($item->name, $locale) + ?? data_get($item->name, 'en') + ?? (is_string($item->name) ? $item->name : collect($item->name ?? [])->filter()->first()) + ?? 'Homepage'; + @endphp + +
+
+ Önizleme +
+ +
+
+
+
+
+

{{ $displayName }}

+
+
+
+
+
+ +
+ © {{ date('Y') }} +
+
+ + diff --git a/modules/Cms/Resources/views/homepage_test/form.blade.php b/modules/Cms/Resources/views/homepage_test/form.blade.php new file mode 100644 index 000000000..d92faa5a5 --- /dev/null +++ b/modules/Cms/Resources/views/homepage_test/form.blade.php @@ -0,0 +1,28 @@ +@extends("{$MODULARITY_VIEW_NAMESPACE}::layouts.master") + +@section('appTypeClass', 'body--form') + +@php + +@endphp + +@push('head_last_js') + {{ + ModularityVite::useHotFile(public_path('modularity.hot'))->withEntryPoints(['src/js/core-form.js']) + }} +@endpush + +@section('content') + +@stop + +@push('post_js') + +@endpush + +@push('STORE') + window['{{ modularityConfig('js_namespace') }}'].ENDPOINTS = {!! json_encode($endpoints ?? new StdClass()) !!} + window['{{ modularityConfig('js_namespace') }}'].STORE.form = {!! json_encode($formStore ?? new StdClass()) !!} +@endpush diff --git a/modules/Cms/Resources/views/homepage_test/index.blade.php b/modules/Cms/Resources/views/homepage_test/index.blade.php new file mode 100644 index 000000000..031905e9e --- /dev/null +++ b/modules/Cms/Resources/views/homepage_test/index.blade.php @@ -0,0 +1,23 @@ +@extends("$MODULARITY_VIEW_NAMESPACE::layouts.master") + +@section('appTypeClass', 'body--listing') + +@php + $customTableAttributes = []; +@endphp + +@push('head_last_js') + {{ + ModularityVite::useHotFile(public_path('modularity.hot'))->withEntryPoints(['src/js/core-index.js']) + }} +@endpush + +@section('content') + @include("$MODULARITY_VIEW_NAMESPACE::components.table", array_merge($tableAttributes ?? [], $customTableAttributes)) +@stop + +@push('STORE') + window['{{ modularityConfig('js_namespace') }}'].ENDPOINTS = {!! json_encode($endpoints ?? new StdClass()) !!} + window['{{ modularityConfig('js_namespace') }}'].STORE.form = {!! json_encode($formStore ?? new StdClass()) !!} + window['{{ modularityConfig('js_namespace') }}'].STORE.datatable = {!! json_encode($tableStore ?? new StdClass()) !!} +@endpush diff --git a/modules/Cms/Resources/views/page/custom.blade.php b/modules/Cms/Resources/views/page/custom.blade.php new file mode 100644 index 000000000..ee0634400 --- /dev/null +++ b/modules/Cms/Resources/views/page/custom.blade.php @@ -0,0 +1,129 @@ + + + + + + {{ $seoTitle ?? ('Preview — ' . ($item->title ?? 'Page')) }} + @if(! empty($seoDescription)) + + @endif + @if(! empty($canonicalUrl)) + + @endif + @if(! empty($robotsMeta)) + + @endif + + + +@php + $locale = app()->getLocale(); + $item->loadMissing(['files', 'medias', 'fileponds']); + + $translatedMedias = modularityConfig('media_library.translated_form_fields', false); + + $documentFiles = $item->files->filter(function ($file) use ($locale) { + return $file->pivot->role === 'documents' && $file->pivot->locale === $locale; + }); + + $photoMedias = $item->medias->filter(function ($media) use ($locale, $translatedMedias) { + if ($media->pivot->role !== 'photos' || $media->pivot->crop !== 'default') { + return false; + } + if ($translatedMedias) { + return $media->pivot->locale === $locale; + } + + return true; + }); + + $attachmentFileponds = $item->fileponds->filter(function ($fp) use ($locale) { + return $fp->role === 'attachments' && $fp->locale === $locale; + }); + + $filepondPreviewRoute = \Illuminate\Support\Facades\Route::has('filepond.preview'); + + $sessions = $item->repeaters->where('role', 'sessions')->first()?->content ?? []; +@endphp +
+ @include('cms::components.responsive-title', ['title' => $item->title ?? null]) + +
+
+ {{ __('Files') }} (documents) +
+
+ @forelse ($documentFiles as $file) + @php $docUrl = $item->file('documents', $locale, $file); @endphp + + @empty +

{{ __('No files for this locale.') }}

+ @endforelse +
+
+ +
+
+ {{ __('Images') }} (photos) +
+
+ @forelse ($photoMedias as $media) + @php + $src = $item->image('photos', 'default', [], false, false, $media, $locale); + $alt = $item->imageAltText('photos', $media); + @endphp +
+ {{ $alt }} + @if ($caption = $item->imageCaption('photos', $media)) +
{{ $caption }}
+ @endif +
+ @empty +

{{ __('No images for this locale.') }}

+ @endforelse +
+
+ +
+
+ {{ __('Attachments') }} (filepond) +
+
+ @forelse ($attachmentFileponds as $fp) +
+ @if ($filepondPreviewRoute) + {{ $fp->file_name }} + {{-- {{ $fp->file_name }} --}} + @else + {{ $fp->file_name }} — {{ __('Preview route not registered (filepond.preview).') }} + @endif +
+ @empty +

{{ __('No attachments for this locale.') }}

+ @endforelse +
+
+ + @forelse ($sessions as $session) +
+
+ {{ $session['session_title'] }} (title) +
+
+

{{ $session['session_description'] }}

+
+
+ @empty +

{{ __('No sessions for this locale.') }}

+ @endforelse +
+ + diff --git a/modules/Cms/Resources/views/public/page.blade.php b/modules/Cms/Resources/views/public/page.blade.php new file mode 100644 index 000000000..ef977ce53 --- /dev/null +++ b/modules/Cms/Resources/views/public/page.blade.php @@ -0,0 +1,30 @@ + + + + + + {{ $seoTitle ?? (optional($translation)->seo_title ?? optional($translation)->title ?? 'Page') }} + @if(! empty($seoDescription)) + + @elseif(!empty(optional($translation)->seo_description)) + + @endif + @if(! empty($canonicalUrl)) + + @endif + @if(! empty($robotsMeta)) + + @endif + + +
+

{{ optional($translation)->title ?? '' }}

+ @if(!empty(optional($translation)->excerpt)) +

{{ $translation->excerpt }}

+ @endif +
+ {!! optional($translation)->content ?? '' !!} +
+
+ + diff --git a/modules/Cms/Routes/.gitkeep b/modules/Cms/Routes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Routes/api.php b/modules/Cms/Routes/api.php new file mode 100644 index 000000000..f2b1064cc --- /dev/null +++ b/modules/Cms/Routes/api.php @@ -0,0 +1,48 @@ +group(function () { + $promotionStepUpMiddleware = (modularityConfig('cms_features.register_middlewares', true) && modularityConfig('security.enabled', false)) + ? 'modularity.security.step_up:promotion.execute' + : null; + + $redirectBulkStepUpMiddleware = (modularityConfig('cms_features.register_middlewares', true) && modularityConfig('security.enabled', false)) + ? 'modularity.security.step_up:redirect.bulk_import' + : null; + + Route::prefix('cms')->name('cms.')->group(function () use ($promotionStepUpMiddleware, $redirectBulkStepUpMiddleware) { + Route::get('routing-meta', CmsRoutingMetaController::class)->name('routingMeta'); + + Route::apiResource('parent-segments', ParentSegmentController::class)->only(['index', 'store', 'update', 'destroy']); + + $dryRunRoute = Route::post('promotion/dry-run', [PromotionController::class, 'dryRun']) + ->name('promotion.dryRun'); + + $executeRoute = Route::post('promotion/execute', [PromotionController::class, 'execute']) + ->name('promotion.execute'); + + if ($promotionStepUpMiddleware) { + $dryRunRoute->middleware($promotionStepUpMiddleware); + $executeRoute->middleware($promotionStepUpMiddleware); + } + + $redirectBulkDryRun = Route::post('redirects/bulk/dry-run', [RedirectController::class, 'bulkSheetDryRun']) + ->name('redirects.bulk.dryRun'); + + $redirectBulkCommit = Route::post('redirects/bulk/commit', [RedirectController::class, 'bulkSheetCommit']) + ->name('redirects.bulk.commit'); + + if ($redirectBulkStepUpMiddleware) { + $redirectBulkDryRun->middleware($redirectBulkStepUpMiddleware); + $redirectBulkCommit->middleware($redirectBulkStepUpMiddleware); + } + + Route::get('redirects/bulk/export', [RedirectController::class, 'bulkSheetExport']) + ->name('redirects.bulk.export'); + }); +}); diff --git a/modules/Cms/Routes/front.php b/modules/Cms/Routes/front.php new file mode 100644 index 000000000..1e1fdc0e2 --- /dev/null +++ b/modules/Cms/Routes/front.php @@ -0,0 +1,27 @@ +name(curtModuleRouteNamePrefix(__FILE__) . '.')->group(function () { + // $register = modularityConfig('cms_features.register_middlewares', true); + + // $useCanonicalLocaleMiddleware = $register + // && modularityConfig('cms_routing.redirect_to_canonical', false); + + // $useVisitorRedirect = $register + // && modularityConfig('cms_routing.visitor_redirects_enabled', true); + + // $middlewares = array_values(array_filter([ + // 'web', + // $useCanonicalLocaleMiddleware ? 'modules.cms.canonical.locale' : null, + // $useVisitorRedirect ? 'modules.cms.visitor.redirect' : null, + // ])); + + // Route::middleware($middlewares)->group(function () { + // if (modularityConfig('cms_routing.public_pages_enabled', true)) { + // Route::get('/{path?}', PublicPageController::class) + // ->where('path', '.*') + // ->name('page'); + // } + // }); +}); diff --git a/modules/Cms/Routes/web.php b/modules/Cms/Routes/web.php new file mode 100644 index 000000000..10c3c671d --- /dev/null +++ b/modules/Cms/Routes/web.php @@ -0,0 +1,95 @@ +group(function () { + if (modularityConfig('cms_routing.signed_preview.enabled', true)) { + Route::get('signed-public-preview/{module}/{route}/{id}', SignedPublicPreviewMintController::class) + ->where([ + 'module' => '[A-Za-z][A-Za-z0-9]*', + 'route' => '[A-Za-z][A-Za-z0-9]*', + 'id' => '[0-9]+', + ]) + ->name('signed_public_preview.mint'); + } + + Route::get('promotion', PromotionToolController::class) + ->name('promotion.tool'); + + // Route::get('redirects/bulk', [RedirectController::class, 'bulkSheetTool']) + // ->name('redirects.bulk.tool'); + + Route::get('site-seo', SiteSeoToolController::class) + ->name('siteSeo.tool'); + + $stepUpEnabled = modularityConfig('cms_features.register_middlewares', true) && modularityConfig('security.enabled', false); + + $promotionStepUpMiddleware = $stepUpEnabled + ? 'modularity.security.step_up:promotion.execute' + : null; + + $redirectBulkStepUpMiddleware = $stepUpEnabled + ? 'modularity.security.step_up:redirect.bulk_import' + : null; + + $siteSeoStepUpMiddleware = $stepUpEnabled + ? 'modularity.security.step_up:site_seo.edit' + : null; + + // SİTEMAP PANEL STEP-UP MIDDLEWARE + $sitemapCommitAbility = (string) modularityConfig('cms_sitemap.panel.step_up_ability.commit', 'sitemap.commit'); + $sitemapCommitStepUpMiddleware = $stepUpEnabled + ? 'modularity.security.step_up:' . $sitemapCommitAbility + : null; + + $siteSeoSave = Route::post('site-seo', [SiteSeoSettingsController::class, 'update']) + ->name('siteSeo.save'); + + if ($siteSeoStepUpMiddleware) { + $siteSeoSave->middleware($siteSeoStepUpMiddleware); + } + + $sitemapDryRun = Route::post('sitemap/dry-run', [SitemapController::class, 'dryRun']) + ->name('sitemap.dryRun.web'); + + $sitemapCommit = Route::post('sitemap/commit', [SitemapController::class, 'commit']) + ->name('sitemap.commit.web'); + + if ($sitemapCommitStepUpMiddleware) { + $sitemapDryRun->middleware($sitemapCommitStepUpMiddleware); + $sitemapCommit->middleware($sitemapCommitStepUpMiddleware); + } + + Route::post('sitemap/item', [SitemapController::class, 'upsertItem']) + ->name('sitemap.item.upsert.web'); + + // PROMOTION PANEL STEP-UP MIDDLEWARE + $dryRunRoute = Route::post('promotion/dry-run', [PromotionController::class, 'dryRun']) + ->name('promotion.dryRun.web'); + + $executeRoute = Route::post('promotion/execute', [PromotionController::class, 'execute']) + ->name('promotion.execute.web'); + + if ($promotionStepUpMiddleware) { + $dryRunRoute->middleware($promotionStepUpMiddleware); + $executeRoute->middleware($promotionStepUpMiddleware); + } +}); diff --git a/modules/Cms/Routing/CmsFrontRouteLocalizationBinding.php b/modules/Cms/Routing/CmsFrontRouteLocalizationBinding.php new file mode 100644 index 000000000..a2a2b30e6 --- /dev/null +++ b/modules/Cms/Routing/CmsFrontRouteLocalizationBinding.php @@ -0,0 +1,106 @@ + '{locale}', ...])} + * so the first segment is a real route parameter (works with {@see LaravelLocalization} middleware). + * + * @param Closure():void $register Registers the inner route(s), typically {@code GET {path}} catch-all. + */ + public static function wrapLocalizedRouteGroupIfEnabled(Closure $register): void + { + if (! self::shouldUseLocalePrefixRouteGroup()) { + $register(); + + return; + } + + $keys = self::localeKeysForRouteConstraint(); + if ($keys === []) { + $register(); + + return; + } + + $pattern = self::implodeLocalesAsRegexAlternation($keys); + + Route::group([ + 'prefix' => '{locale}', + 'where' => ['locale' => $pattern], + ], function () use ($register): void { + $register(); + }); + } + + public static function shouldUseLocalePrefixRouteGroup(): bool + { + if ((string) modularityConfig('cms_routing.public_front_route_group_mode', 'catch_all') !== 'locale_param') { + return false; + } + + return self::isMcamaraLocalizationStackAvailable(); + } + + public static function isMcamaraLocalizationStackAvailable(): bool + { + $driver = (string) modularityConfig('cms_routing.localization_driver', 'auto'); + + if ($driver === 'mcamara') { + return class_exists(LaravelLocalization::class); + } + + if ($driver === 'translatable') { + return false; + } + + return class_exists(LaravelLocalization::class); + } + + /** + * @return list + */ + public static function localeKeysForRouteConstraint(): array + { + if (app()->bound(CmsLocalizationContract::class)) { + return app(CmsLocalizationContract::class)->pathSegmentLocales(); + } + + if (class_exists(LaravelLocalization::class)) { + try { + $keys = LaravelLocalization::getSupportedLanguagesKeys(); + if (is_array($keys) && $keys !== []) { + return array_values(array_map('strval', $keys)); + } + } catch (\Throwable) { + } + } + + return array_values(array_unique(array_map('strval', getLocales()))); + } + + /** + * Alternation order is longest-first so {@code pt-br} wins over {@code pt}. + * + * @param list $localeKeys + */ + public static function implodeLocalesAsRegexAlternation(array $localeKeys): string + { + $sorted = $localeKeys; + usort($sorted, fn (string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a)); + + return implode('|', array_map(static fn (string $k): string => preg_quote($k, '/'), $sorted)); + } +} diff --git a/modules/Cms/Routing/CmsFrontRouteRegistrar.php b/modules/Cms/Routing/CmsFrontRouteRegistrar.php new file mode 100644 index 000000000..c98a2d6ee --- /dev/null +++ b/modules/Cms/Routing/CmsFrontRouteRegistrar.php @@ -0,0 +1,493 @@ +getRouteNames() as $routeName) { + if (! $module->isEnabledRoute($routeName)) { + continue; + } + + try { + $model = $module->getModel($routeName, true); + } catch (\Throwable) { + continue; + } + + if (! classHasTrait($model, HasParentSegment::class)) { + continue; + } + + $controllerFqcn = $module->getTargetClassNamespace( + 'front-controller', + Str::studly($routeName) . 'Controller' + ); + + if (! class_exists($controllerFqcn)) { + continue; + } + + if (! is_subclass_of($controllerFqcn, CmsController::class, true)) { + continue; + } + + return $controllerFqcn; + } + + return null; + } + + /** + * Use {@see CmsPublicFrontController} for the Cms module when config is on and there is at least one enabled + * {@link ParentSegment} row (registry), so the catch-all is not tied to route iteration order. + */ + private static function shouldUseUniversalCmsPublicFrontForModule(Module $module): bool + { + if (! (bool) modularityConfig('cms_routing.universal_cms_public_front', true)) { + return false; + } + + if ($module->getName() !== 'Cms') { + return false; + } + + if (! class_exists(CmsPublicFrontController::class)) { + return false; + } + + if (! Schema::hasTable((new ParentSegment)->getTable())) { + return false; + } + + return ParentSegment::query()->where('enabled', true)->exists(); + } + + /** + * Invokable {@see CmsController} for a specific enabled submodule, when its model uses {@see HasParentSegment}. + * + * @return class-string|null + */ + public static function resolveFrontControllerForModuleRoute(Module $module, string $routeName): ?string + { + if (self::shouldUseUniversalCmsPublicFrontForModule($module)) { + return CmsPublicFrontController::class; + } + + if (! $module->isEnabledRoute($routeName)) { + return null; + } + + try { + $model = $module->getModel($routeName, true); + } catch (\Throwable) { + return null; + } + + if (! classHasTrait($model, HasParentSegment::class)) { + return null; + } + + return self::resolveFrontControllerForModelClass(get_class($model)); + } + + /** + * {@code GET {modulePrefix}/{path?}} with CMS middleware stack. + */ + public static function registerUnderModulePrefix(Module $module): void + { + $controller = self::resolveFrontControllerForModule($module); + if ($controller === null) { + return; + } + + $routeNamePrefix = $module->routeNamePrefix() . '.'; + + $group = [ + // 'prefix' => $module->prefix(), + 'as' => $routeNamePrefix, + ]; + + $domain = self::resolvePublicFrontRouteDomain(); + if ($domain !== null) { + $group['domain'] = $domain; + } + + Route::group($group, function () use ($controller): void { + self::registerInnerCatchAll($controller); + }); + } + + /** + * Register middleware + optional `GET /{path?}` in the current route group (relative path). + * + * Used by {@see registerUnderModulePrefix()} and legacy {@see Route::cmsPublicFrontRoutes()} macro. + */ + public static function register(): void + { + if (! modularityConfig('cms_features.enabled', true)) { + return; + } + + $controller = self::resolveControllerClassOrNull(); + if ($controller === null) { + return; + } + + self::registerInnerCatchAll($controller); + } + + /** + * @param class-string $controller Invokable front controller FQCN + */ + private static function registerInnerCatchAll(string $controller): void + { + $middlewares = self::resolveMiddlewareStack(); + + /* + * `locale_param` mode wraps routes as `{locale}/{path}` — URLs without a leading locale (slugless canonical) + * never match unless we also register `{path}` on the same host (see fallback_locale_optional_path_segment). + * `catch_all` keeps a single `{path}`; locale lives inside `{path}` and is parsed in PHP. + */ + if (! CmsFrontRouteLocalizationBinding::shouldUseLocalePrefixRouteGroup()) { + self::registerPathCatchAllRoute($controller, $middlewares, 'page'); + + return; + } + + CmsFrontRouteLocalizationBinding::wrapLocalizedRouteGroupIfEnabled(static function () use ($controller, $middlewares): void { + self::registerPathCatchAllRoute($controller, $middlewares, 'page.locale'); + }); + + if ((bool) modularityConfig('cms_routing.fallback_locale_optional_path_segment', false)) { + self::registerPathCatchAllRoute($controller, $middlewares, 'page'); + } + } + + /** + * @param string $suffix Route name suffix after the owning module prefix (typically {@code cms.} → {@code cms.page}). + */ + private static function registerPathCatchAllRoute(string $controller, array $middlewares, string $suffix): void + { + $pathPattern = self::catchAllPathParameterPattern(); + + Route::middleware($middlewares)->group(static function () use ($controller, $suffix, $pathPattern): void { + Route::get('{path}', $controller) + ->where('path', $pathPattern) + ->name($suffix); + }); + } + + /** + * {@code {path}} constraint so reserved top-level segments (signed public preview, etc.) reach their own routes. + */ + private static function catchAllPathParameterPattern(): string + { + $blocked = []; + + if (modularityConfig('cms_routing.signed_preview.enabled', true)) { + $preview = trim((string) modularityConfig('cms_routing.signed_preview.path_prefix', 'cms/preview'), '/'); + if ($preview !== '') { + $blocked[] = preg_quote($preview, '/'); + } + } + + foreach ((array) modularityConfig('cms_routing.public_front_catch_all_exclude_path_prefixes', []) as $raw) { + if (! is_string($raw)) { + continue; + } + $p = trim($raw, '/'); + if ($p !== '') { + $blocked[] = preg_quote($p, '/'); + } + } + + $blocked = array_values(array_unique($blocked)); + if ($blocked === []) { + return '.*'; + } + + $alt = implode('|', $blocked); + + return '^(?!(?:' . $alt . ')(?:/|$)).*$'; + } + + /** + * @return list + */ + private static function resolveMiddlewareStack(): array + { + $register = modularityConfig('cms_features.register_middlewares', true); + + $useCanonicalLocaleMiddleware = $register + && modularityConfig('cms_routing.redirect_to_canonical', false); + + $useFallbackSluglessCanonicalMiddleware = $register + && (bool) modularityConfig('cms_routing.fallback_locale_optional_path_segment', false); + + $useVisitorRedirect = $register + && modularityConfig('cms_routing.visitor_redirects_enabled', true); + + $useMcamaraRoutesMiddleware = $register && self::shouldAppendMcamaraRoutesMiddleware(); + + return array_values(array_filter([ + 'web', + $useFallbackSluglessCanonicalMiddleware ? 'modules.cms.fallback.slugless.canonical' : null, + $useMcamaraRoutesMiddleware ? \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class : null, + $useCanonicalLocaleMiddleware ? 'modules.cms.canonical.locale' : null, + $useVisitorRedirect ? 'modules.cms.visitor.redirect' : null, + ])); + } + + private static function shouldAppendMcamaraRoutesMiddleware(): bool + { + if (! class_exists(\Mcamara\LaravelLocalization\Facades\LaravelLocalization::class)) { + return false; + } + + if (CmsFrontRouteLocalizationBinding::shouldUseLocalePrefixRouteGroup()) { + return (bool) modularityConfig('cms_routing.public_front_mcamara_middleware_with_locale_param', true); + } + + return (bool) modularityConfig('cms_routing.public_front_mcamara_middleware_with_catch_all', false); + } + + /** + * First resolvable front controller for any enabled {@see ParentSegment} target (global gate + legacy macro). + * + * @return class-string|null + */ + public static function resolveControllerClassOrNull(): ?string + { + if (! modularityConfig('cms_routing.public_pages_enabled', true)) { + return null; + } + + if (! Schema::hasTable((new ParentSegment)->getTable())) { + return null; + } + + if (! ParentSegment::query()->where('enabled', true)->exists()) { + return null; + } + + if ((bool) modularityConfig('cms_routing.universal_cms_public_front', true) && class_exists(CmsPublicFrontController::class)) { + return CmsPublicFrontController::class; + } + + $targets = ParentSegment::query() + ->where('enabled', true) + ->select('target_model_class') + ->groupBy('target_model_class') + ->orderBy('target_model_class') + ->pluck('target_model_class'); + + foreach ($targets as $modelClass) { + if (! is_string($modelClass) || $modelClass === '' || ! class_exists($modelClass)) { + continue; + } + + if (! classHasTrait($modelClass, HasParentSegment::class)) { + continue; + } + + $resolved = self::resolveFrontControllerForModelClass($modelClass); + if ($resolved !== null) { + return $resolved; + } + } + + return null; + } + + /** + * Resolve invokable front controller for a concrete model class (ParentSegment {@code target_model_class}). + * Uses {@see modularityConfig('cms_routing.public_front_handlers')} override when set, otherwise + * {@see Module::getTargetClassNamespace()} + {@see CmsController} subclass check. + * + * @param class-string $modelClass + * @return class-string|null + */ + public static function resolveFrontControllerForModelClass(string $modelClass): ?string + { + $configured = modularityConfig('cms_routing.public_front_handlers', []); + if (is_array($configured) && isset($configured[$modelClass])) { + $override = $configured[$modelClass]; + if (is_string($override) && $override !== '' && class_exists($override) + && is_subclass_of($override, CmsController::class, true)) { + return $override; + } + } + + foreach (Modularity::allEnabled() as $module) { + if (! $module instanceof Module) { + continue; + } + + foreach ($module->getRouteNames() as $routeName) { + if (! $module->isEnabledRoute($routeName)) { + continue; + } + + try { + $model = $module->getModel($routeName, true); + } catch (\Throwable) { + continue; + } + + if (get_class($model) !== $modelClass) { + continue; + } + + if (! classHasTrait($model, HasParentSegment::class)) { + continue; + } + + $fqcn = $module->getTargetClassNamespace( + 'front-controller', + Str::studly($routeName) . 'Controller' + ); + + if (! class_exists($fqcn) || ! is_subclass_of($fqcn, CmsController::class, true)) { + continue; + } + + return $fqcn; + } + } + + return null; + } +} diff --git a/modules/Cms/Services/CanonicalUrlResolver.php b/modules/Cms/Services/CanonicalUrlResolver.php new file mode 100644 index 000000000..421c59734 --- /dev/null +++ b/modules/Cms/Services/CanonicalUrlResolver.php @@ -0,0 +1,97 @@ +getHost())); + $defaultLocale = (string) ($options['default_locale'] ?? modularityConfig('cms_routing.default_locale', app()->getLocale())); + $hideDefaultLocale = (bool) ($options['hide_default_locale_segment'] ?? modularityConfig('cms_routing.hide_default_locale_segment', false)); + $redirectToCanonical = (bool) ($options['redirect_to_canonical'] ?? modularityConfig('cms_routing.redirect_to_canonical', true)); + + $locale = $locale ?: $defaultLocale; + $normalizedPath = $this->normalizePath($path); + + $localePrefix = ($locale === $defaultLocale && $hideDefaultLocale) + ? '' + : '/' . trim($locale, '/'); + + $withoutLocale = $this->stripLocalePrefix( + $normalizedPath, + array_values(array_unique(array_filter([$locale, $defaultLocale, ...CmsPathLocale::pathSegmentLocales()]))) + ); + $canonicalPath = rtrim($localePrefix . '/' . ltrim($withoutLocale, '/'), '/'); + $canonicalPath = $canonicalPath === '' ? '/' : $canonicalPath; + + $canonicalUrl = 'https://' . $canonicalHost . $canonicalPath; + + $effectiveHost = $host ?: request()->getHost(); + $incomingUrl = 'https://' . $effectiveHost . $normalizedPath; + $shouldRedirect = $redirectToCanonical && $incomingUrl !== $canonicalUrl; + + return [ + 'canonical_url' => $canonicalUrl, + 'canonical_path' => $canonicalPath, + 'normalized_path' => $normalizedPath, + 'should_redirect' => $shouldRedirect, + 'redirect_to' => $shouldRedirect ? $canonicalUrl : null, + 'locale' => $locale, + ]; + } + + public function normalizePath(string $path): string + { + $path = trim($path); + $path = '/' . ltrim($path, '/'); + + if (modularityConfig('cms_seo.canonical.force_lowercase_path', true)) { + $path = mb_strtolower($path); + } + + $path = preg_replace('#/+#', '/', $path) ?: '/'; + + if (modularityConfig('cms_seo.canonical.trim_trailing_slash', true) && mb_strlen($path) > 1) { + $path = rtrim($path, '/'); + } + + return $path; + } + + public function normalizedPathRegistryLookupVariants(string $pathKey): array + { + $canonical = $this->normalizePath($pathKey); + $variants = [$canonical]; + + if ($canonical !== '/' && $canonical !== '') { + $withoutSlash = ltrim($canonical, '/'); + if ($withoutSlash !== '' && $withoutSlash !== $canonical) { + $variants[] = $withoutSlash; + } + } + + return array_values(array_unique($variants)); + } + + protected function stripLocalePrefix(string $path, array $locales = []): string + { + $locales = $locales === [] ? CmsPathLocale::pathSegmentLocales() : $locales; + + foreach ($locales as $locale) { + $needle = '/' . trim((string) $locale, '/'); + if ($path === $needle) { + return '/'; + } + + if (str_starts_with($path, $needle . '/')) { + return mb_substr($path, mb_strlen($needle)); + } + } + + return $path; + } +} diff --git a/modules/Cms/Services/CmsAdminWarnings.php b/modules/Cms/Services/CmsAdminWarnings.php new file mode 100644 index 000000000..26ebe4822 --- /dev/null +++ b/modules/Cms/Services/CmsAdminWarnings.php @@ -0,0 +1,120 @@ + + */ + public function gather(Model $model): array + { + $warnings = []; + + $warnings = array_merge($warnings, $this->pathOverlapWarnings($model)); + + if (modularityConfig('cms_seo.admin.publish_soft_warnings', true) && $model->published) { + $warnings = array_merge($warnings, $this->publishSeoSoftWarnings($model)); + } + + if (modularityConfig('cms_seo.admin.publish_schedule_warnings', true) && $model->published) { + $warnings = array_merge($warnings, $this->publishScheduleWarnings($model)); + } + + return array_values(array_unique(array_filter($warnings, static fn ($w) => $w !== null && $w !== ''))); + } + + /** + * @return list + */ + protected function pathOverlapWarnings(Model $model): array + { + if (! $this->urlRouteRegistry->tableReady()) { + return []; + } + + $warnings = []; + + $pathsByLocale = $this->urlRouteRegistry->publicPagePathsByLocale($model); + + foreach ($pathsByLocale as $locale => $path) { + $warnings = array_merge( + $warnings, + $this->urlRouteRegistry->nestedPathPrefixWarnings( + (string) $locale, + (string) $path, + UrlRoute::KIND_PAGE_PUBLIC, + $model->getMorphClass(), + (int) $model->getKey() + ) + ); + } + + return $warnings; + } + + /** + * @return list + */ + protected function publishSeoSoftWarnings(Model $model): array + { + $warnings = []; + + $model->loadMissing('translations'); + + foreach ($model->translations as $translation) { + if (! $translation->active) { + continue; + } + + $locale = (string) $translation->locale; + + if (trim((string) $translation->seo_title) === '') { + $warnings[] = sprintf('SEO title is empty for locale "%s".', $locale); + } + + if (trim((string) $translation->seo_description) === '') { + $warnings[] = sprintf('SEO description is empty for locale "%s".', $locale); + } + } + + return $warnings; + } + + /** + * @return list + */ + protected function publishScheduleWarnings(Model $model): array + { + $warnings = []; + $now = Carbon::now(); + + if ($model->publish_start_date && $now->lt($model->publish_start_date)) { + $warnings[] = sprintf( + 'Not publicly visible yet: scheduled publish starts at %s.', + $model->publish_start_date->toIso8601String() + ); + } + + if ($model->publish_end_date && $now->gt($model->publish_end_date)) { + $warnings[] = sprintf( + 'Not publicly visible anymore: scheduled publish ended at %s.', + $model->publish_end_date->toIso8601String() + ); + } + + return $warnings; + } +} diff --git a/modules/Cms/Services/CmsParentSegmentResolver.php b/modules/Cms/Services/CmsParentSegmentResolver.php new file mode 100644 index 000000000..45447cdd4 --- /dev/null +++ b/modules/Cms/Services/CmsParentSegmentResolver.php @@ -0,0 +1,271 @@ +getTable()); + } + + /** + * Normalized path prefix for this model class + locale (e.g. `/blog`), {@code '/'} when the binding stores a deliberate + * blank prefix (locale-root homepage), or null when no applicable enabled binding remains. + */ + public function normalizedPrefixForTargetLocale(string $targetClass, string $locale): ?string + { + if (! $this->enabled() || ! $this->tablesReady()) { + return null; + } + + $binding = $this->resolvePreferredEnabledBindingOrFallbackLocale($targetClass, (string) $locale); + + return $this->normalizedPathFromBinding($binding); + } + + /** + * Full public path: optional parent prefix + slug leaf (per-locale slug string). + */ + public function joinPublicLeafPath(string $targetClass, string $locale, string $slugLeaf): string + { + $prefix = $this->normalizedPrefixForTargetLocale($targetClass, $locale); + $leaf = trim($slugLeaf, '/'); + if ($leaf === '') { + return $prefix ?? '/'; + } + if ($prefix === null || $prefix === '' || $prefix === '/') { + return $this->canonicalUrlResolver->normalizePath('/' . $leaf); + } + + $merged = rtrim($prefix, '/') . '/' . ltrim($leaf, '/'); + + return $this->canonicalUrlResolver->normalizePath($merged); + } + + /** + * @return array locale => normalized prefix path (for admin preview / routing-meta). + */ + public function normalizedPrefixesMapForTargetClass(string $targetClass): array + { + if (! $this->enabled() || ! $this->tablesReady()) { + return []; + } + + $aliases = $this->targetModelAliases($targetClass); + $out = []; + + $wildcard = ParentSegment::query() + ->whereIn('target_model_class', $aliases) + ->where('enabled', true) + ->where('locale', '') + ->orderBy('sort_order') + ->first(); + + if ($wildcard !== null) { + $raw = trim((string) ($wildcard->normalized_prefix ?? '')); + $out['*'] = $raw !== '' + ? $this->canonicalUrlResolver->normalizePath('/' . ltrim($raw, '/')) + : '/'; + } + + $orderedLocales = []; + $seenLocale = []; + + foreach (getLocales() as $loc) { + $loc = (string) $loc; + if (isset($seenLocale[$loc])) { + continue; + } + $seenLocale[$loc] = true; + $orderedLocales[] = $loc; + } + + foreach ($this->localesWithBindingsForAliases($aliases) as $loc) { + $loc = (string) $loc; + if (isset($seenLocale[$loc])) { + continue; + } + $seenLocale[$loc] = true; + $orderedLocales[] = $loc; + } + + foreach ($orderedLocales as $loc) { + $path = $this->normalizedPrefixForTargetLocale($targetClass, $loc); + if ($path !== null) { + $out[$loc] = $path; + } + } + + return $out; + } + + /** + * @param list $aliases + * @return list + */ + private function localesWithBindingsForAliases(array $aliases): array + { + if ($aliases === []) { + return []; + } + + return ParentSegment::query() + ->whereIn('target_model_class', $aliases) + ->where('locale', '!=', '') + ->distinct() + ->orderBy('locale') + ->pluck('locale') + ->map(fn ($loc) => (string) $loc) + ->values() + ->all(); + } + + /** + * @return list + */ + private function targetModelAliases(string $targetClass): array + { + $aliases = [$targetClass]; + if (class_exists($targetClass) && is_a($targetClass, Model::class, true)) { + try { + /** @phpstan-ignore-next-line safe new for morph alias lookup */ + $aliases[] = (new $targetClass)->getMorphClass(); + } catch (\Throwable) { + } + } + + /** @var list */ + return array_values(array_unique(array_filter(array_map('strval', $aliases)))); + } + + private function resolvePreferredEnabledBindingOrFallbackLocale(string $targetClass, string $locale): ?ParentSegment + { + $preferred = $this->fetchEnabledBindingForLocalePreference($targetClass, $locale); + + if ($preferred !== null) { + return $preferred; + } + + foreach ($this->fallbackLocaleCandidatesForSegments($targetClass) as $candidate) { + if ($candidate === $locale) { + continue; + } + $picked = $this->fetchEnabledBindingForLocalePreference($targetClass, $candidate); + if ($picked !== null) { + return $picked; + } + } + + return null; + } + + /** + * @return list + */ + private function fallbackLocaleCandidatesForSegments(string $targetClass): array + { + $candidates = []; + $candidates[] = (string) modularityConfig('cms_routing.default_locale', config('app.locale')); + + $transFallback = config('translatable.fallback_locale'); + if (is_string($transFallback) && $transFallback !== '') { + $candidates[] = $transFallback; + } + + foreach (getLocales() as $loc) { + $candidates[] = (string) $loc; + } + + $aliases = $this->targetModelAliases($targetClass); + foreach ($this->localesWithEnabledBindings($aliases) as $loc) { + $candidates[] = $loc; + } + + /** @var list */ + return array_values(array_unique(array_filter($candidates))); + } + + /** + * @param list $aliases + * @return list + */ + private function localesWithEnabledBindings(array $aliases): array + { + if ($aliases === []) { + return []; + } + + return ParentSegment::query() + ->whereIn('target_model_class', $aliases) + ->where('enabled', true) + ->where('locale', '!=', '') + ->distinct() + ->orderBy('locale') + ->pluck('locale') + ->map(fn ($loc) => (string) $loc) + ->values() + ->all(); + } + + /** + * Normalized binding prefix path, or {@code /} when the DB stores a deliberately blank prefix (locale-root URLs). + */ + private function normalizedPathFromBinding(?ParentSegment $binding): ?string + { + if ($binding === null) { + return null; + } + + $raw = trim((string) ($binding->normalized_prefix ?? '')); + + if ($raw === '') { + return '/'; + } + + return $this->canonicalUrlResolver->normalizePath('/' . ltrim($raw, '/')); + } + + /** + * One enabled binding: exact locale beats empty string (wildcard) for the same targets. + */ + private function fetchEnabledBindingForLocalePreference(string $targetClass, string $preferredLocale): ?ParentSegment + { + $aliases = $this->targetModelAliases($targetClass); + if ($aliases === []) { + return null; + } + + return ParentSegment::query() + ->whereIn('target_model_class', $aliases) + ->where('enabled', true) + ->where(function ($q) use ($preferredLocale): void { + $q->where('locale', $preferredLocale)->orWhere('locale', ''); + }) + ->orderByRaw('CASE WHEN locale = ? THEN 0 ELSE 1 END', [$preferredLocale]) + ->orderBy('sort_order') + ->first(); + } +} diff --git a/modules/Cms/Services/CmsPromotionService.php b/modules/Cms/Services/CmsPromotionService.php new file mode 100644 index 000000000..871add458 --- /dev/null +++ b/modules/Cms/Services/CmsPromotionService.php @@ -0,0 +1,492 @@ +resolveScope((array) ($input['scope'] ?? [])); + + return [ + 'dry_run' => (bool) ($input['dry_run'] ?? true), + 'scope' => $scope, + 'checkpoint' => modularityConfig('cms_promotion.approval.checkpoint_label', 'approval-checkpoint'), + 'steps' => [ + 'validate', + 'dry_run_diff', + 'approval_check', + 'apply', + 'cache_flush', + 'audit_log', + ], + ]; + } + + public function promote(array $input = [], ?Authenticatable $user = null): array + { + $user = $user ?? $this->resolveUserFromPayload($input); + + $scope = $this->resolveScope((array) ($input['scope'] ?? [])); + $dryRun = (bool) ($input['dry_run'] ?? modularityConfig('cms_promotion.dry_run_required', true)); + + if (modularityConfig('cms_promotion.approval.enabled', true) && ! $this->securityService->canPromote($user)) { + return [ + 'ok' => false, + 'stage' => 'approval_check', + 'message' => 'User is not allowed to approve or execute promotion.', + ]; + } + + $report = [ + 'ok' => true, + 'dry_run' => $dryRun, + 'scope' => $scope, + 'diff' => $this->dryRunDiff($scope), + 'cache_flushed' => false, + ]; + + if (! $dryRun) { + $report['cache_flushed'] = $this->flushModularityCache(); + if (modularityConfig('cms_promotion.execute.flush_laravel_cache', false)) { + Cache::flush(); + $report['laravel_cache_flushed'] = true; + } + + $report['scope_effects'] = $this->scopeApplier->applyAfterPromotion($scope, [ + 'dry_run' => false, + 'user' => $user, + 'diff' => $report['diff'], + 'report' => $report, + ]); + + Event::dispatch(new CmsPromotionExecuted($scope, $report, $user)); + + $this->recordPromotionAudit($user, $scope, $report); + } + + Log::channel($this->auditLogChannel())->info('CMS promotion executed', [ + 'dry_run' => $dryRun, + 'scope' => $scope, + 'executed_by' => $user?->getAuthIdentifier(), + 'executed_by_email' => $user?->email ?? null, + ]); + + return $report; + } + + protected function auditLogChannel(): string + { + return (string) modularityConfig('cms_promotion.audit.log_channel', 'modularity'); + } + + /** + * Structured log + optional Spatie activity entry when the helper exists. + */ + protected function recordPromotionAudit(?Authenticatable $user, array $scope, array $report): void + { + if (! modularityConfig('cms_promotion.audit.activity_log', true)) { + return; + } + + if (! function_exists('activity')) { + return; + } + + try { + $act = activity() + ->withProperties([ + 'scope' => $scope, + 'cache_flushed' => $report['cache_flushed'] ?? false, + 'diff_meta' => $report['diff']['meta'] ?? [], + ]) + ->event('cms_promotion'); + + if ($user instanceof Model) { + $act->causedBy($user); + } + + $act->log('cms_promotion_execute'); + } catch (\Throwable) { + // Activity table / package optional in some installs + } + } + + /** + * Resolve an authenticated user when promotion runs from a queued job (no HTTP guard). + * + * @param array $input + */ + protected function resolveUserFromPayload(array $input): ?Authenticatable + { + $id = $input['user_id'] ?? null; + if ($id === null || $id === '') { + return null; + } + + $model = config('auth.providers.users.model'); + if (! is_string($model) || ! class_exists($model)) { + return null; + } + + return $model::query()->find($id); + } + + /** + * @param array $scope + */ + protected function dryRunDiff(array $scope): array + { + $primary = $this->buildScopeSnapshots($scope, null); + + $meta = [ + 'generated_at' => now()->toIso8601String(), + 'environment' => app()->environment(), + 'source_connection' => (string) config('database.default'), + 'note' => 'Primary snapshot uses the default application DB connection. When `cms_promotion.compare.connection` is set, a second snapshot and numeric deltas are included.', + ]; + + $out = array_merge(['meta' => $meta], $primary); + + $compare = (string) modularityConfig('cms_promotion.compare.connection', ''); + if ($compare !== '') { + try { + $this->assertCompareConnectionAllowed($compare); + $secondary = $this->buildScopeSnapshots($scope, $compare); + $out['comparison'] = [ + 'enabled' => true, + 'target_connection' => $compare, + 'target_label' => (string) modularityConfig('cms_promotion.compare.label', $compare), + 'count_delta' => $this->diffIntegerLeaves($primary, $secondary), + ]; + if (modularityConfig('cms_promotion.compare.include_full_target_snapshot', false)) { + $out['comparison']['target_snapshots'] = $secondary; + } + } catch (\Throwable $e) { + $out['comparison'] = [ + 'enabled' => true, + 'error' => $e->getMessage(), + ]; + } + } + + return $out; + } + + /** + * @param array $scope + * @return array + */ + protected function buildScopeSnapshots(array $scope, ?string $connectionName): array + { + return [ + 'settings_changes' => $this->scopeIncludes($scope, 'settings') + ? $this->summarizeSettings($connectionName) + : [], + 'content_changes' => $this->scopeIncludes($scope, 'content') + ? $this->summarizeContent($connectionName) + : [], + 'seo_changes' => $this->scopeIncludes($scope, 'seo') + ? $this->summarizeSeo($connectionName) + : [], + 'redirect_changes' => $this->scopeIncludes($scope, 'redirects') + ? $this->summarizeRedirects($connectionName) + : [], + 'layout_changes' => $this->scopeIncludes($scope, 'layouts') + ? $this->summarizeLayouts($connectionName) + : [], + ]; + } + + /** + * Recursively subtract matching integer values (same array shape) for quick cross-DB drift. + * + * @return array + */ + protected function diffIntegerLeaves(array $a, array $b): array + { + $out = []; + $this->walkIntegerDiff($a, $b, '', $out); + + return $out; + } + + /** + * @param array $out + */ + protected function walkIntegerDiff(mixed $a, mixed $b, string $path, array &$out): void + { + if (is_int($a) && is_int($b) && $a !== $b) { + $out[$path === '' ? 'value' : $path] = $a - $b; + + return; + } + + if (! is_array($a) || ! is_array($b)) { + return; + } + + foreach ($a as $key => $va) { + $childPath = $path === '' ? (string) $key : $path . '.' . $key; + if (! array_key_exists($key, $b)) { + continue; + } + $this->walkIntegerDiff($va, $b[$key], $childPath, $out); + } + } + + protected function assertCompareConnectionAllowed(string $name): void + { + $allowed = (array) modularityConfig('cms_promotion.compare.allowed_connections', []); + if ($allowed === []) { + return; + } + + if (! in_array($name, $allowed, true)) { + throw new \InvalidArgumentException("Promotion compare connection [{$name}] is not in cms_promotion.compare.allowed_connections."); + } + } + + /** + * @param array $scope + */ + protected function scopeIncludes(array $scope, string $key): bool + { + return (bool) ($scope[$key] ?? false); + } + + protected function cmsTable(string $key, string $default): string + { + return (string) modularityConfig('tables.' . $key, $default); + } + + protected function db(?string $connectionName): Connection + { + if ($connectionName === null || $connectionName === '') { + return DB::connection(); + } + + return DB::connection($connectionName); + } + + protected function schemaHasTable(?string $connectionName, string $table): bool + { + if ($connectionName === null || $connectionName === '') { + return Schema::hasTable($table); + } + + return Schema::connection($connectionName)->hasTable($table); + } + + protected function summarizeSettings(?string $connectionName = null): array + { + $table = $this->cmsTable('cms_site_settings', 'um_cms_site_settings'); + if (! $this->schemaHasTable($connectionName, $table)) { + return $this->tableMissingPayload($table); + } + + $q = $this->db($connectionName)->table($table); + + return [ + 'available' => true, + 'table' => $table, + 'connection' => $connectionName ?? (string) config('database.default'), + 'total_rows' => (int) $q->count(), + 'active_rows' => (int) $this->db($connectionName)->table($table)->where('is_active', true)->count(), + 'rows_by_group' => $this->db($connectionName)->table($table) + ->select('group_key', DB::raw('count(*) as c')) + ->groupBy('group_key') + ->orderByDesc('c') + ->limit(25) + ->get() + ->pluck('c', 'group_key') + ->all(), + ]; + } + + /** + * @return array + */ + protected function summarizeContent(?string $connectionName = null): array + { + $pageTable = $this->cmsTable('cms_pages', 'um_cms_pages'); + $revisionTable = $this->cmsTable('cms_pages_revisions', 'um_cms_pages_revisions'); + $routeTable = $this->cmsTable('cms_url_routes', 'um_cms_url_routes'); + + $out = [ + 'available' => true, + 'pages' => [], + 'page_revisions' => [], + 'url_routes' => [], + ]; + + if ($this->schemaHasTable($connectionName, $pageTable)) { + $out['pages'] = [ + 'table' => $pageTable, + 'connection' => $connectionName ?? (string) config('database.default'), + 'total_rows' => (int) $this->db($connectionName)->table($pageTable)->count(), + 'published_rows' => (int) $this->db($connectionName)->table($pageTable)->where('published', true)->count(), + ]; + } else { + $out['pages'] = $this->tableMissingPayload($pageTable); + } + + if ($this->schemaHasTable($connectionName, $revisionTable)) { + $out['page_revisions'] = [ + 'table' => $revisionTable, + 'pending_rows' => (int) $this->db($connectionName)->table($revisionTable) + ->where('status', RevisionStatus::Pending->value) + ->count(), + ]; + } else { + $out['page_revisions'] = $this->tableMissingPayload($revisionTable); + } + + if ($this->schemaHasTable($connectionName, $routeTable)) { + $out['url_routes'] = [ + 'table' => $routeTable, + 'total_rows' => (int) $this->db($connectionName)->table($routeTable)->count(), + 'rows_by_kind' => $this->db($connectionName)->table($routeTable) + ->select('kind', DB::raw('count(*) as c')) + ->groupBy('kind') + ->orderByDesc('c') + ->get() + ->pluck('c', 'kind') + ->all(), + ]; + } else { + $out['url_routes'] = $this->tableMissingPayload($routeTable); + } + + return $out; + } + + /** + * @return array + */ + protected function summarizeSeo(?string $connectionName = null): array + { + $table = $this->cmsTable('cms_page_translations', 'um_cms_page_translations'); + if (! $this->schemaHasTable($connectionName, $table)) { + return $this->tableMissingPayload($table); + } + + $withMeta = (int) $this->db($connectionName)->table($table)->where(function ($q): void { + $q->where(function ($q2): void { + $q2->whereNotNull('seo_title')->where('seo_title', '!=', ''); + })->orWhere(function ($q3): void { + $q3->whereNotNull('seo_description')->where('seo_description', '!=', ''); + }); + })->count(); + + $total = (int) $this->db($connectionName)->table($table)->count(); + + return [ + 'available' => true, + 'table' => $table, + 'connection' => $connectionName ?? (string) config('database.default'), + 'translation_rows_with_seo_fields' => $withMeta, + 'translation_rows_total' => $total, + ]; + } + + /** + * @return array + */ + protected function summarizeRedirects(?string $connectionName = null): array + { + $table = $this->cmsTable('cms_redirects', 'um_cms_redirects'); + if (! $this->schemaHasTable($connectionName, $table)) { + return $this->tableMissingPayload($table); + } + + return [ + 'available' => true, + 'table' => $table, + 'connection' => $connectionName ?? (string) config('database.default'), + 'total_rows' => (int) $this->db($connectionName)->table($table)->count(), + 'active_rows' => (int) $this->db($connectionName)->table($table)->where('is_active', true)->count(), + ]; + } + + /** + * @return array + */ + protected function summarizeLayouts(?string $connectionName = null): array + { + $table = $this->cmsTable('cms_pages', 'um_cms_pages'); + if (! $this->schemaHasTable($connectionName, $table)) { + return $this->tableMissingPayload($table); + } + + $rows = $this->db($connectionName)->table($table) + ->whereNotNull('layout') + ->where('layout', '!=', '') + ->select('layout', DB::raw('count(*) as c')) + ->groupBy('layout') + ->orderByDesc('c') + ->limit(40) + ->get() + ->pluck('c', 'layout') + ->all(); + + return [ + 'available' => true, + 'table' => $table, + 'connection' => $connectionName ?? (string) config('database.default'), + 'distinct_layout_keys' => count($rows), + 'rows_by_layout' => $rows, + ]; + } + + /** + * @return array{available: false, table: string} + */ + protected function tableMissingPayload(string $table): array + { + return [ + 'available' => false, + 'table' => $table, + ]; + } + + protected function resolveScope(array $requestedScope): array + { + $defaults = (array) modularityConfig('cms_promotion.scope', []); + + if ($requestedScope === []) { + return $defaults; + } + + return array_merge($defaults, $requestedScope); + } + + protected function flushModularityCache(): bool + { + if (app()->bound('modularity.cache')) { + app('modularity.cache')->flush(); + + return true; + } + + return false; + } +} diff --git a/modules/Cms/Services/CmsPublicModelResolver.php b/modules/Cms/Services/CmsPublicModelResolver.php new file mode 100644 index 000000000..8ee61ca6e --- /dev/null +++ b/modules/Cms/Services/CmsPublicModelResolver.php @@ -0,0 +1,211 @@ +findPublicUrlRouteRow($request, $urlRouteKind); + + if ($row === null) { + return null; + } + + $candidate = $row->urlable; + if ($candidate === null) { + return null; + } + + $modelClass = get_class($candidate); + if (! CmsParentSegmentRegistryGate::allowsModelClass($modelClass)) { + return null; + } + + $locale = (string) $row->locale; + + return $this->loadPublishedModel($modelClass, $candidate->getKey(), $locale); + } + + /** + * @param class-string $modelClass + */ + public function resolve(Request $request, string $modelClass, string $urlRouteKind): ?Model + { + if (! modularityConfig('cms_routing.public_pages_enabled', true)) { + return null; + } + + if (! is_a($modelClass, Model::class, true)) { + return null; + } + + $row = $this->findPublicUrlRouteRow($request, $urlRouteKind); + if ($row === null) { + return null; + } + + $candidate = $row->urlable; + if (! is_a($candidate, $modelClass, true)) { + return null; + } + + $locale = (string) $row->locale; + + return $this->loadPublishedModel($modelClass, $candidate->getKey(), $locale); + } + + /** + * Find the {@link UrlRoute} for the current path/locale; applies locale to the app when implicit. + */ + private function findPublicUrlRouteRow(Request $request, string $urlRouteKind): ?UrlRoute + { + if (! modularityConfig('cms_routing.public_pages_enabled', true)) { + return null; + } + + if (! Schema::hasTable((new UrlRoute)->getTable())) { + return null; + } + + [$locale, $pathKey, $explicitLocalePrefix] = $this->visitorPathResolver->resolveLocalePathKeyAndExplicitFlag($request); + + $this->cmsLocalization->applyLocaleToApplication($locale); + + $pathVariants = $this->canonicalUrlResolver->normalizedPathRegistryLookupVariants($pathKey); + + $row = UrlRoute::query() + ->where('locale', $locale) + ->whereIn('normalized_path', $pathVariants) + ->where('kind', $urlRouteKind) + ->first(); + + if ($row === null && ! $explicitLocalePrefix) { + $row = $this->resolveUrlRouteWhenLocaleImplicit($pathKey, $urlRouteKind); + if ($row !== null) { + $locale = (string) $row->locale; + $this->cmsLocalization->applyLocaleToApplication($locale); + } + } + + return $row; + } + + /** + * @param class-string $modelClass + */ + private function loadPublishedModel(string $modelClass, int|string $key, string $locale): ?Model + { + $query = $modelClass::query()->whereKey($key); + $this->applyPublishedVisibilityScopes($query, $modelClass); + + if (method_exists($modelClass, 'translations')) { + $query->with(['translations' => fn ($q) => $q->where('locale', $locale)]); + } + + return $query->first(); + } + + /** + * Load a CMS entity by primary key for signed public preview (bypasses {@code published} / {@code visible} scopes). + * + * @param class-string $modelClass + */ + public function resolveByIdBypassingPublicationScopes(string $modelClass, int|string $id, string $locale): ?Model + { + if (! is_a($modelClass, Model::class, true)) { + return null; + } + + $this->cmsLocalization->applyLocaleToApplication($locale); + + $query = $modelClass::query()->whereKey($id); + + if (method_exists($modelClass, 'translations')) { + $query->with(['translations' => fn ($q) => $q->where('locale', $locale)]); + } + + return $query->first(); + } + + /** + * @param class-string $modelClass + */ + private function applyPublishedVisibilityScopes(Builder $query, string $modelClass): void + { + $scopes = []; + foreach (['published', 'visible'] as $name) { + $method = 'scope' . Str::studly($name); + if (method_exists($modelClass, $method)) { + $scopes[] = $name; + } + } + if ($scopes !== []) { + $query->scopes($scopes); + } + } + + /** + * Without an explicit {@code /{locale}/} segment in the URL, only {@see UrlRoute} rows for the implicit editorial + * locale may match (slugless fallback when enabled — see {@see CmsSluglessFallbackLocale}; otherwise CMS default). + */ + private function resolveUrlRouteWhenLocaleImplicit(string $pathKey, string $urlRouteKind): ?UrlRoute + { + $pathVariants = $this->canonicalUrlResolver->normalizedPathRegistryLookupVariants($pathKey); + + $rows = UrlRoute::query() + ->whereIn('normalized_path', $pathVariants) + ->where('kind', $urlRouteKind) + ->get(); + + /** + * Without an explicit {@code /{locale}/} segment, URLs must only bind to rows for the implicit editorial locale + * (slugless fallback when enabled — see {@see CmsSluglessFallbackLocale}; else CMS default). + * Otherwise a TR-only slug like {@code /sayfalar/deneme-2} would resolve under a prefix-less URL intended for EN. + */ + $implicitLocale = CmsSluglessFallbackLocale::implicitPreferredLocaleOtherwise($this->cmsLocalization->defaultLocale()); + $rows = $rows->filter( + fn (UrlRoute $r): bool => CmsSluglessFallbackLocale::sameLocale((string) $r->locale, $implicitLocale) + )->values(); + + if ($rows->isEmpty()) { + return null; + } + + if ($rows->count() === 1) { + return $rows->first(); + } + + return $rows->firstWhere(fn (UrlRoute $r): bool => CmsSluglessFallbackLocale::sameLocale((string) $r->locale, app()->getLocale())) + ?? $rows->first(); + } +} diff --git a/modules/Cms/Services/CmsSignedPreviewTargetResolver.php b/modules/Cms/Services/CmsSignedPreviewTargetResolver.php new file mode 100644 index 000000000..713e04185 --- /dev/null +++ b/modules/Cms/Services/CmsSignedPreviewTargetResolver.php @@ -0,0 +1,48 @@ +}|null + */ + public function resolve(string $moduleSegment, string $routeSegment): ?array + { + $module = Collection::make(Modularity::allEnabled())->first( + fn ($m) => studlyName($m->getName()) === studlyName($moduleSegment) + ); + + if (! $module instanceof Module) { + return null; + } + + $routeKey = Collection::make($module->getRouteNames())->first( + fn ($r) => studlyName($r) === studlyName($routeSegment) + ); + + if ($routeKey === null || ! $module->isEnabledRoute($routeKey)) { + return null; + } + + $frontFqcn = CmsFrontRouteRegistrar::resolveFrontControllerForModuleRoute($module, $routeKey); + if ($frontFqcn === null) { + return null; + } + + return [ + 'module' => $module, + 'routeKey' => $routeKey, + 'frontControllerFqcn' => $frontFqcn, + ]; + } +} diff --git a/modules/Cms/Services/CmsSignedPreviewUrlGenerator.php b/modules/Cms/Services/CmsSignedPreviewUrlGenerator.php new file mode 100644 index 000000000..40e553588 --- /dev/null +++ b/modules/Cms/Services/CmsSignedPreviewUrlGenerator.php @@ -0,0 +1,39 @@ +addMinutes($minutes); + + return URL::temporarySignedRoute( + 'cms.signed_preview.show', + $expiresAt, + [ + 'module' => studlyName($moduleName), + 'route' => studlyName($routeKey), + 'id' => $model->getKey(), + 'locale' => $locale, + ], + true + ); + } + + public function ttlMinutes(): int + { + return max(5, (int) modularityConfig('cms_routing.signed_preview.ttl_minutes', 60)); + } +} diff --git a/modules/Cms/Services/CmsSiteSeoSettingsService.php b/modules/Cms/Services/CmsSiteSeoSettingsService.php new file mode 100644 index 000000000..d87f719ac --- /dev/null +++ b/modules/Cms/Services/CmsSiteSeoSettingsService.php @@ -0,0 +1,97 @@ +persistedGlobalRobotsTxt(); + if ($persisted !== null) { + $raw = trim($persisted); + if ($raw === '') { + $raw = null; + } + } + } + + if ($raw === null) { + $raw = trim((string) modularityConfig('cms_seo.robots.global_robots_txt', $default)); + } + + if ($raw === '') { + $raw = trim($default); + } + + return $raw . "\n"; + } + + /** + * Raw value from DB, or null when unset (use env/config in UI and public fallback). + */ + public function persistedGlobalRobotsTxt(): ?string + { + [$g, $k, $locale] = $this->robotsSettingKeys(); + $row = $this->siteSettings->findScoped($g, $k, $locale); + + return $row !== null ? (string) $row->value : null; + } + + /** + * Text shown in the panel editor: DB value if set, otherwise the effective env default (without forcing trailing newline). + */ + public function globalRobotsTxtForEditor(): string + { + $persisted = $this->persistedGlobalRobotsTxt(); + if ($persisted !== null) { + return rtrim($persisted, "\r\n"); + } + + $default = "User-agent: *\nAllow: /"; + $raw = trim((string) modularityConfig('cms_seo.robots.global_robots_txt', $default)); + if ($raw === '') { + $raw = trim($default); + } + + return $raw; + } + + public function saveGlobalRobotsTxt(?string $value): void + { + [$g, $k, $locale] = $this->robotsSettingKeys(); + $this->siteSettings->putScoped($g, $k, $locale, $value); + } + + /** + * @return array{0: string, 1: string, 2: string} + */ + protected function robotsSettingKeys(): array + { + $cfg = (array) modularityConfig('cms_seo.robots.site_setting', []); + + return [ + (string) ($cfg['group_key'] ?? 'seo'), + (string) ($cfg['key'] ?? 'global_robots_txt'), + (string) ($cfg['locale'] ?? '*'), + ]; + } +} diff --git a/modules/Cms/Services/CmsSitemapBuildService.php b/modules/Cms/Services/CmsSitemapBuildService.php new file mode 100644 index 000000000..3bc8778fd --- /dev/null +++ b/modules/Cms/Services/CmsSitemapBuildService.php @@ -0,0 +1,418 @@ + + */ + public function buildEntryDtos(): array + { + $dtos = []; + + $this->eachIncludedSitemapLine( + function ( + UrlRoute $route, + Model $model, + string $groupKey, + array $override, + string $loc, + ?string $lastmod, + string $locale + ) use (&$dtos): void { + + $dtos[] = [ + 'loc' => $loc, + 'lastmod' => $lastmod, + 'changefreq' => $override['changefreq'], + 'priority' => $override['priority'], + 'key' => $groupKey, + 'hreflang' => $this->hreflangForLocale($locale), + 'locale' => $locale, + ]; + } + ); + + return $this->attachAlternatesToDtos($dtos); + } + + /** + * One row per included {@link UrlRoute} line (before hreflang alternates), for the admin sitemap panel. + * + * @return list + */ + public function getPanelItemRows(): array + { + $idsByGroup = $this->loadSitemapableItemIdsByGroup(); + $rows = []; + $sort = 0; + + $this->eachIncludedSitemapLine( + function ( + UrlRoute $route, + Model $model, + string $groupKey, + array $override, + string $loc, + ?string $lastmod, + string $locale + ) use (&$rows, &$sort, $idsByGroup): void { + $rows[] = [ + 'sort' => ++$sort, + 'url_route_id' => (int) $route->getKey(), + 'group_key' => $groupKey, + 'urlable_type' => (string) $route->urlable_type, + 'urlable_id' => (int) $route->urlable_id, + 'locale' => $locale, + 'normalized_path' => (string) $route->normalized_path, + 'loc' => $loc, + 'lastmod' => $lastmod, + 'changefreq' => $override['changefreq'], + 'priority' => $override['priority'], + 'sitemapable_item_id' => $idsByGroup[$groupKey] ?? null, + ]; + } + ); + + return $rows; + } + + /** + * Invokes the callback for each (route, model) that would be emitted as a sitemap {@code } line + * (same filter rules as {@see buildEntryDtos} before hreflang alternates are attached). + * + * @param callable(UrlRoute, Model, string, array{changefreq: string, priority: string}, string, string|null, string): void $callback + * route, model, groupKey, override, loc, lastmod, locale + */ + private function eachIncludedSitemapLine(callable $callback): void + { + if (! Schema::hasTable((new UrlRoute)->getTable())) { + return; + } + + $routes = UrlRoute::query() + ->where('kind', UrlRoute::KIND_PAGE_PUBLIC) + ->orderBy('urlable_type') + ->orderBy('urlable_id') + ->orderBy('locale') + ->get(); + + if ($routes->isEmpty()) { + return; + } + + $overrides = $this->loadOverrides(); + $grouped = $routes->groupBy(fn (UrlRoute $r) => $r->urlable_type . ':' . $r->urlable_id); + + + foreach ($grouped as $groupKey => $group) { + /** @var \Illuminate\Support\Collection $group */ + $first = $group->first(); + if ($first === null) { + continue; + } + $class = $first->urlable_type; + if (! is_string($class) || ! is_a($class, Model::class, true)) { + continue; + } + + if (! CmsParentSegmentRegistryGate::allowsModelClass($class)) { + continue; + } + $ids = $group->pluck('urlable_id')->unique()->values(); + $valid = $this->loadValidModels($class, $ids); + + if ($valid->isEmpty()) { + continue; + } + $override = $this->matchOverride($overrides, $class, (int) $ids->first()); + + /** @var UrlRoute $route */ + foreach ($group as $route) { + $id = (int) $route->urlable_id; + $model = $valid->get($id) ?? $valid->get((string) $id); + if ($model === null) { + continue; + } + $locale = (string) $route->locale; + if (! $this->shouldIncludeInSitemapForLocale($model, $locale)) { + continue; + } + $path = (string) $route->normalized_path; + $browser = CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath( + $locale, + $path, + $this->canonical + ); + $loc = CmsPublicSiteUrl::absoluteUrlForPath($browser); + $lastmod = $this->resolveLastmod($model); + $callback($route, $model, (string) $groupKey, $override, $loc, $lastmod, $locale); + } + } + } + + /** + * Morph group key (FQCN:id) => {@link CmsSitemapableItem} id. + * + * @return array + */ + private function loadSitemapableItemIdsByGroup(): array + { + $sitemapId = (int) modularityConfig('cms_sitemap.default_sitemap_id', 1); + $t = (new CmsSitemapableItem)->getTable(); + if (! Schema::hasTable($t)) { + return []; + } + $out = []; + CmsSitemapableItem::query() + ->where('sitemap_id', $sitemapId) + ->get() + ->each(function (CmsSitemapableItem $row) use (&$out): void { + $out[$row->sitemapable_type . ':' . (string) $row->sitemapable_id] = (int) $row->id; + }); + + return $out; + } + + /** + * @param list> $annotated Pre-built rows (e.g. tests); if empty, runs {@see buildEntryDtos()}. + */ + public function toXmlString(array $annotated = []): string + { + if ($annotated === []) { + $annotated = $this->buildEntryDtos(); + } + + $lines = [ + '', + '', + ]; + + foreach ($annotated as $row) { + $lines[] = ' '; + $lines[] = ' ' . $this->xml($row['loc'] ?? '') . ''; + if (! empty($row['lastmod'])) { + $lines[] = ' ' . $this->xml((string) $row['lastmod']) . ''; + } + if (! empty($row['changefreq'])) { + $lines[] = ' ' . $this->xml((string) $row['changefreq']) . ''; + } + if (isset($row['priority']) && $row['priority'] !== '' && $row['priority'] !== null) { + $lines[] = ' ' . $this->xml((string) $row['priority']) . ''; + } + $alts = $row['alternates'] ?? []; + if (is_array($alts)) { + foreach ($alts as $alt) { + if (! is_array($alt) || ! isset($alt['href'], $alt['hreflang'])) { + continue; + } + $lines[] = ' '; + } + } + $lines[] = ' '; + } + + $lines[] = ''; + + return implode("\n", $lines) . "\n"; + } + + public function buildXml(): string + { + return $this->toXmlString(); + } + + /** + * @return array{changefreq: string, priority: string} + */ + private function defaultOverride(): array + { + $defaults = (array) modularityConfig('cms_sitemap.defaults', []); + + $changefreq = (string) data_get($defaults, 'changefreq', 'weekly'); + $priority = data_get($defaults, 'priority', 0.5); + if (is_numeric($priority)) { + $priority = number_format((float) $priority, 1, '.', ''); + } else { + $priority = '0.5'; + } + + return ['changefreq' => $changefreq, 'priority' => (string) $priority]; + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + private function loadValidModels(string $class, Collection $ids): Collection + { + $keyName = (new $class)->getKeyName(); + $q = $class::query()->whereIn($keyName, $ids->all()); + foreach (['published', 'visible'] as $name) { + $method = 'scope' . Str::studly($name); + if (method_exists($class, $method)) { + $q->scopes([$name]); + } + } + + return $q->get()->keyBy(fn (Model $m) => $m->getKey()); + } + + private function shouldIncludeInSitemapForLocale(Model $model, string $locale): bool + { + + if ( classHasTrait($model, HasTranslation::class)) { + $t = $model->translate($locale); + if ($t === null) { + return false; + } + + if (property_exists($t, 'sitemap_include') || isset($t->sitemap_include)) { + return (bool) $t->sitemap_include; + } + } else if ( classHasTrait($model, HasTranslatableMetadata::class) && classHasTrait($model, IsSingular::class)) { + + if((property_exists($model, 'sitemap_include') || isset($model->sitemap_include)) && is_array($model->sitemap_include) && Arr::isAssoc($model->sitemap_include)) { + return (bool) $model->sitemap_include[$locale] ?? true; + } + } + + return true; + } + + private function resolveLastmod(Model $model): ?string + { + $t = $model->updated_at; + if ($t === null) { + return null; + } + + return $t->toIso8601String(); + } + + private function hreflangForLocale(string $locale): string + { + $locale = str_replace('_', '-', $locale); + if (str_contains($locale, '-')) { + $parts = explode('-', $locale, 2); + + return mb_strtolower($parts[0]) . '-' . mb_strtoupper($parts[1] ?? ''); + } + + return mb_strtolower($locale); + } + + /** + * @return array + */ + private function loadOverrides(): array + { + $sitemapId = (int) modularityConfig('cms_sitemap.default_sitemap_id', 1); + $t = (new CmsSitemapableItem)->getTable(); + if (! Schema::hasTable($t)) { + return []; + } + $defaults = $this->defaultOverride(); + $out = []; + CmsSitemapableItem::query() + ->where('sitemap_id', $sitemapId) + ->get() + ->each(function (CmsSitemapableItem $row) use (&$out, $defaults): void { + $key = $row->sitemapable_type . ':' . (string) $row->sitemapable_id; + $p = $row->priority !== null ? number_format((float) $row->priority, 1, '.', '') : $defaults['priority']; + $out[$key] = [ + 'changefreq' => $row->changefreq !== null && $row->changefreq !== '' ? (string) $row->changefreq : $defaults['changefreq'], + 'priority' => $p, + ]; + }); + + return $out; + } + + /** + * @param array $overrides + * @return array{changefreq: string, priority: string} + */ + private function matchOverride(array $overrides, string $class, int $id): array + { + $key = $class . ':' . $id; + $def = $this->defaultOverride(); + if (isset($overrides[$key])) { + return $overrides[$key]; + } + + return $def; + } + + /** + * @param list $dtos + * @return list> + */ + private function attachAlternatesToDtos(array $dtos): array + { + if ($dtos === []) { + return []; + } + $defaultLocale = $this->cmsLocalization->defaultLocale(); + $byKey = collect($dtos)->groupBy('key'); + $out = []; + foreach ($byKey as $rows) { + $coll = $rows->values(); + $alternates = []; + foreach ($coll as $r) { + if (! is_array($r) || ! isset($r['loc'], $r['hreflang'])) { + continue; + } + $alternates[] = ['href' => (string) $r['loc'], 'hreflang' => (string) $r['hreflang']]; + } + $xDefault = $coll->firstWhere('locale', $defaultLocale); + if ($xDefault === null && $coll->isNotEmpty()) { + $xDefault = $coll->first(); + } + $xDefaultLoc = is_array($xDefault) && isset($xDefault['loc']) ? (string) $xDefault['loc'] : null; + if ($xDefaultLoc !== null && $xDefaultLoc !== '') { + $alternates[] = ['href' => $xDefaultLoc, 'hreflang' => 'x-default']; + } + foreach ($coll as $r) { + if (is_array($r)) { + $r['alternates'] = $alternates; + $out[] = $r; + } + } + } + + return $out; + } + + private function xml(string $s): string + { + return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } +} diff --git a/modules/Cms/Services/CmsSitemapCacheService.php b/modules/Cms/Services/CmsSitemapCacheService.php new file mode 100644 index 000000000..d70a40377 --- /dev/null +++ b/modules/Cms/Services/CmsSitemapCacheService.php @@ -0,0 +1,47 @@ +buildXml(); + $this->commit($xml); + + return $xml; + } + + return $this->emptyUrlset(); + } + + public function commit(string $xml): void + { + $key = (string) modularityConfig('cms_sitemap.cache_key', 'modularity_cms_sitemap.committed_v1'); + Cache::forever($key, $xml); + } + + public function emptyUrlset(): string + { + return <<<'XML' + + + + +XML; + } +} diff --git a/modules/Cms/Services/CmsSlugInputValidationService.php b/modules/Cms/Services/CmsSlugInputValidationService.php new file mode 100644 index 000000000..cffb15e64 --- /dev/null +++ b/modules/Cms/Services/CmsSlugInputValidationService.php @@ -0,0 +1,138 @@ +slugPathSegmentPolicyFailure($value); + if ($segmentFailure !== null) { + return [ + 'valid' => false, + 'message' => $segmentFailure, + 'normalized' => '', + ]; + } + + return $this->validateModelSlugWithPublicUrlRegistry($modelClass, $value, $locale, $localeScoped, $excludeId); + } + + /** + * When {@see modularityConfig('cms_routing.admin.slug_max_path_segments')} is set, reject raw values whose + * slash-separated segment count exceeds the limit (admin path policy). + */ + protected function slugPathSegmentPolicyFailure(string $rawValue): ?string + { + $max = modularityConfig('cms_routing.admin.slug_max_path_segments'); + if ($max === null || $max === '') { + return null; + } + + $max = (int) $max; + if ($max < 1) { + return null; + } + + $trimmed = trim($rawValue); + if ($trimmed === '') { + return null; + } + + $normalizedSlashes = str_replace('\\', '/', $trimmed); + $segments = array_values(array_filter(explode('/', trim($normalizedSlashes, '/')), static fn ($s) => $s !== '')); + + if (count($segments) > $max) { + return __('The slug may contain at most :max URL segment(s).', ['max' => $max]); + } + + return null; + } + + protected function slugModelUsesPublicUrlRegistry(string $modelClass): bool + { + return classHasTrait($modelClass, HasParentSegment::class); + } + + protected function nestedPublicUrlRegistryWarningsEnabled(): bool + { + return (bool) modularityConfig('cms_routing.admin.slug_nested_path_warnings', true); + } + + /** + * @param array{valid: bool, message: ?string, normalized: string} $parentResult + */ + protected function registryPathFromNormalizedSlug( + string $modelClass, + array $parentResult, + string $value, + string $locale, + ): ?string { + if (! classHasTrait($modelClass, HasParentSegment::class)) { + return null; + } + + return $this->parentSegmentResolver->joinPublicLeafPath( + $modelClass, + (string) $locale, + (string) ($parentResult['normalized'] ?? '') + ); + } + + protected function nestedWarningPathFromRawSlug(string $modelClass, string $rawValue, string $locale): ?string + { + if (! classHasTrait($modelClass, HasParentSegment::class)) { + return null; + } + + return $this->parentSegmentResolver->joinPublicLeafPath($modelClass, (string) $locale, ltrim($rawValue, '/')); + } + + protected function publicUrlRegistryRouteKindForNestedWarnings(string $modelClass): ?string + { + if (! classHasTrait($modelClass, HasParentSegment::class)) { + return null; + } + + return UrlRoute::KIND_PAGE_PUBLIC; + } + + protected function publicUrlRegistryMorphClassForNestedWarnings(string $modelClass): ?string + { + return classHasTrait($modelClass, HasParentSegment::class) ? $modelClass : null; + } + + protected function publicUrlRegistryPathCollisionMessage(): string + { + return __('This public URL path is already registered for another page or redirect.'); + } +} diff --git a/modules/Cms/Services/CmsUrlRouteRegistry.php b/modules/Cms/Services/CmsUrlRouteRegistry.php new file mode 100644 index 000000000..fea1832fc --- /dev/null +++ b/modules/Cms/Services/CmsUrlRouteRegistry.php @@ -0,0 +1,484 @@ +getTable()); + } + + /** + * Normalized paths already claimed as {@see UrlRoute::KIND_PAGE_PUBLIC} for this locale. + * When the registry table is missing, returns an empty list (no entity-specific fallbacks). + * + * @return list + */ + public function activePublicPagePathsForLocale(string $locale): array + { + $locale = (string) $locale; + + if (! $this->tableReady()) { + return []; + } + + return UrlRoute::query() + ->where('locale', $locale) + ->where('kind', UrlRoute::KIND_PAGE_PUBLIC) + ->pluck('normalized_path') + ->map(fn ($p) => $this->canonicalUrlResolver->normalizePath((string) $p)) + ->unique() + ->values() + ->all(); + } + + /** + * Resolved public URL paths per locale from slug rows (after sync, matches {@see desiredPublicPathsByLocale}). + * + * @return array locale => normalized path + */ + public function publicPagePathsByLocale(Model $model): array + { + if (classHasTrait($model::class, HasSlug::class)) { + $model->unsetRelation('slugs'); + $model->load('slugs'); + } + + return $this->desiredPublicPathsByLocale($model); + } + + /** + * @return list + */ + public function nestedPathPrefixWarnings( + string $locale, + string $normalizedPath, + string $routeKind, + string $pathOwnerMorphClass, + ?int $exceptUrlableId = null, + ): array { + if (! $this->tableReady()) { + return []; + } + + $normalizedPath = $this->canonicalUrlResolver->normalizePath($normalizedPath); + $warnings = []; + + $rows = UrlRoute::query() + ->where('locale', $locale) + ->where('kind', $routeKind) + ->where('urlable_type', $pathOwnerMorphClass) + ->when($exceptUrlableId !== null, fn ($q) => $q->where('urlable_id', '<>', $exceptUrlableId)) + ->get(['normalized_path', 'urlable_id']); + + foreach ($rows as $row) { + $other = (string) $row->normalized_path; + if ($other === $normalizedPath) { + continue; + } + if (CmsPublicPathHierarchy::segmentsOverlapAsPrefix($normalizedPath, $other)) { + $warnings[] = sprintf( + 'Path segment overlap: "%s" shares a prefix relationship with existing path "%s" (%s id %s).', + $normalizedPath, + $other, + class_basename($pathOwnerMorphClass), + $row->urlable_id + ); + } + } + + return array_values(array_unique($warnings)); + } + + public function removePublicPageRoutesForModel(Model $model): void + { + if (! $this->tableReady()) { + return; + } + + UrlRoute::query() + ->where('urlable_type', $model->getMorphClass()) + ->where('urlable_id', $model->getKey()) + ->where('kind', UrlRoute::KIND_PAGE_PUBLIC) + ->delete(); + } + + public function syncPublicPageRoutesForModel(Model $model): void + { + if (! $this->tableReady()) { + return; + } + + if (classHasTrait($model::class, HasSlug::class)) { + $model->unsetRelation('slugs'); + $model->load('slugs'); + } + + $desiredPathsByLocale = $this->desiredPublicPathsByLocale($model); + $morphClass = $model->getMorphClass(); + + $existingByLocale = UrlRoute::query() + ->where('urlable_type', $morphClass) + ->where('urlable_id', $model->getKey()) + ->where('kind', UrlRoute::KIND_PAGE_PUBLIC) + ->get() + ->keyBy('locale'); + + foreach ($desiredPathsByLocale as $locale => $path) { + $path = $this->canonicalUrlResolver->normalizePath((string) $path); + $row = $existingByLocale->get($locale); + if ($row === null) { + if ($this->pathTakenByAnother($locale, $path)) { + continue; + } + + UrlRoute::query()->create([ + 'locale' => $locale, + 'normalized_path' => $path, + 'urlable_type' => $morphClass, + 'urlable_id' => $model->getKey(), + 'kind' => UrlRoute::KIND_PAGE_PUBLIC, + ]); + + continue; + } + + if ($this->canonicalUrlResolver->normalizePath((string) $row->normalized_path) === $path) { + continue; + } + + if ($this->pathTakenByAnother($locale, $path, $row->id)) { + continue; + } + + $row->update(['normalized_path' => $path]); + } + + foreach ($existingByLocale as $locale => $row) { + if (! array_key_exists($locale, $desiredPathsByLocale)) { + $row->delete(); + } + } + } + + /** + * Rebuilds {@see UrlRoute::KIND_PAGE_PUBLIC} rows for every model of {@code $modelClass} that participates in the + * registry ({@see HasSlug} or {@see IsSingular}). Invoked when {@see \Modules\Cms\Entities\ParentSegment} rows + * change so URL prefixes stay aligned without per-model saves. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + */ + public function syncPublicPageRoutesForAllModelsOfClass(string $modelClass): void + { + if (! $this->tableReady() || ! class_exists($modelClass) || ! is_a($modelClass, Model::class, true)) { + return; + } + + if (! classHasTrait($modelClass, HasSlug::class) && ! classHasTrait($modelClass, IsSingular::class)) { + return; + } + + $chunkSize = max(1, (int) modularityConfig('cms_routing.parent_segment_change_resync_chunk_size', 100)); + + /** @var Model $prototype */ + $prototype = new $modelClass; + $keyName = $prototype->getKeyName(); + + $modelClass::query()->chunkById($chunkSize, function ($models): void { + foreach ($models as $model) { + if ($model instanceof Model) { + $this->syncPublicPageRoutesForModel($model); + } + } + }, $keyName); + } + + public function removeRedirectSourceRoute(Model $redirect): void + { + if (! $this->tableReady()) { + return; + } + + UrlRoute::query() + ->where('urlable_type', $redirect->getMorphClass()) + ->where('urlable_id', $redirect->getKey()) + ->where('kind', UrlRoute::KIND_REDIRECT_SOURCE) + ->delete(); + } + + public function syncRedirectSourceRoute(Model $redirect): void + { + if (! $this->tableReady()) { + return; + } + + if (! $redirect->is_active) { + $this->removeRedirectSourceRoute($redirect); + + return; + } + + $locale = (string) $redirect->locale; + $path = $this->canonicalUrlResolver->normalizePath((string) $redirect->from_path); + $morphClass = $redirect->getMorphClass(); + + $existing = UrlRoute::query() + ->where('urlable_type', $morphClass) + ->where('urlable_id', $redirect->getKey()) + ->where('kind', UrlRoute::KIND_REDIRECT_SOURCE) + ->first(); + + if ($existing === null) { + if ($this->pathTakenByAnother($locale, $path)) { + return; + } + + UrlRoute::query()->create([ + 'locale' => $locale, + 'normalized_path' => $path, + 'urlable_type' => $morphClass, + 'urlable_id' => $redirect->getKey(), + 'kind' => UrlRoute::KIND_REDIRECT_SOURCE, + ]); + + return; + } + + if ($existing->normalized_path === $path && $existing->locale === $locale) { + return; + } + + if ($this->pathTakenByAnother($locale, $path, $existing->id)) { + return; + } + + $existing->update([ + 'normalized_path' => $path, + 'locale' => $locale, + ]); + } + + /** + * One path per configured locale ({@see getLocales()}) plus any extra locales that only appear on slug rows: + * each locale uses its own **active** slug segment when present; otherwise the segment falls back to the first + * available active slug chosen in order: `cms_routing.default_locale`, `translatable.fallback_locale`, + * {@see getLocales()}, then remaining locales that have active slugs. Parent prefix comes from + * {@see CmsParentSegmentResolver} per target locale (e.g. TR uses `sayfalar/…` while the leaf may match EN). + * If no locale has a non-empty active slug, no paths are returned (sync removes page routes). + * + * @return array + */ + protected function desiredPublicPathsByLocale(Model $model): array + { + if (classHasTrait($model::class, IsSingular::class)) { + return $model->parentSegments()->mapWithKeys(function ($parentSegment) { + $trimmed = trim((string) ($parentSegment->normalized_prefix ?? '')); + + $normalized = $trimmed === '' + ? '/' + : $this->canonicalUrlResolver->normalizePath('/' . ltrim($trimmed, '/')); + + return [(string) ($parentSegment->locale ?? '') => $normalized]; + })->toArray(); + } + + if ($model->slugs->isEmpty()) { + return []; + } + + $out = []; + $targetClass = $model::class; + $slugGroups = $model->slugs->groupBy('locale'); + + /** @var array $activeLeafByLocale */ + $activeLeafByLocale = []; + foreach ($slugGroups as $locale => $rows) { + $locale = (string) $locale; + $rows = $rows->values(); + $picked = $rows->first(fn ($s) => (bool) ($s->active ?? false)); + if ($picked === null) { + continue; + } + $segment = trim((string) ($picked->slug ?? '')); + + $activeLeafByLocale[$locale] = $segment; + } + + $canonicalLeaf = $this->canonicalPublicSlugLeafForFallback($activeLeafByLocale); + if ($canonicalLeaf === null) { + return []; + } + + $seenLocale = []; + $localesForPublic = []; + + foreach (getLocales() as $loc) { + $loc = (string) $loc; + if (isset($seenLocale[$loc])) { + continue; + } + $seenLocale[$loc] = true; + $localesForPublic[] = $loc; + } + + foreach ($slugGroups->keys()->all() as $loc) { + $loc = (string) $loc; + if (isset($seenLocale[$loc])) { + continue; + } + $seenLocale[$loc] = true; + $localesForPublic[] = $loc; + } + + foreach ($localesForPublic as $locale) { + $leaf = $activeLeafByLocale[$locale] ?? $canonicalLeaf; + $leaf = trim((string) $leaf); + + $out[$locale] = $this->parentSegmentResolver->joinPublicLeafPath($targetClass, $locale, $leaf); + } + + return $out; + } + + /** + * Pick one active slug string per locale fallback order — prefers non-empty leaves, otherwise allows an explicit empty + * homepage slug when every active binding uses a blank slug segment for that locale bucket. + * + * @param array $activeLeafByLocale + */ + protected function canonicalPublicSlugLeafForFallback(array $activeLeafByLocale): ?string + { + if ($activeLeafByLocale === []) { + return null; + } + + /** @var list */ + $orderedLocales = []; + $push = static function (?string $loc) use (&$orderedLocales): void { + if ($loc === null || $loc === '') { + return; + } + $loc = trim($loc); + if ($loc === '') { + return; + } + $orderedLocales[] = $loc; + }; + + $push(modularityConfig('cms_routing.default_locale', config('app.locale'))); + $transFallback = config('translatable.fallback_locale'); + if (is_string($transFallback) && $transFallback !== '') { + $push($transFallback); + } + foreach (getLocales() as $loc) { + $push((string) $loc); + } + + foreach (array_keys($activeLeafByLocale) as $locale) { + $push((string) $locale); + } + + foreach (array_values(array_unique($orderedLocales)) as $locale) { + if (! isset($activeLeafByLocale[$locale])) { + continue; + } + if ((string) $activeLeafByLocale[$locale] !== '') { + return (string) $activeLeafByLocale[$locale]; + } + } + + foreach (array_values(array_unique($orderedLocales)) as $locale) { + if (! isset($activeLeafByLocale[$locale])) { + continue; + } + + return (string) $activeLeafByLocale[$locale]; + } + + foreach ($activeLeafByLocale as $slug) { + if ((string) $slug !== '') { + return (string) $slug; + } + } + + return ''; + } + + /** + * {@inheritdoc} + */ + public function isPathClaimedByOther( + string $locale, + string $normalizedPath, + ?string $excludeUrlableType = null, + ?int $excludeUrlableId = null, + ): bool { + if (! $this->tableReady()) { + return false; + } + + $path = $this->canonicalUrlResolver->normalizePath($normalizedPath); + + $query = UrlRoute::query() + ->where('locale', $locale) + ->whereIn( + 'normalized_path', + $this->canonicalUrlResolver->normalizedPathRegistryLookupVariants($path) + ); + + if ($excludeUrlableType !== null && $excludeUrlableId !== null) { + $query->where(function ($q) use ($excludeUrlableType, $excludeUrlableId): void { + $q->where('urlable_type', '!=', $excludeUrlableType) + ->orWhere('urlable_id', '<>', $excludeUrlableId); + }); + } + + return $query->exists(); + } + + /** + * @param int|null $exceptId When set, ignore this row (for in-place updates). + */ + protected function pathTakenByAnother(string $locale, string $path, ?int $exceptId = null): bool + { + $canonical = $this->canonicalUrlResolver->normalizePath($path); + + $query = UrlRoute::query() + ->where('locale', $locale) + ->whereIn( + 'normalized_path', + $this->canonicalUrlResolver->normalizedPathRegistryLookupVariants($canonical) + ); + + if ($exceptId !== null) { + $query->where('id', '<>', $exceptId); + } + + return $query->exists(); + } +} diff --git a/modules/Cms/Services/CmsVisitorRedirectResolver.php b/modules/Cms/Services/CmsVisitorRedirectResolver.php new file mode 100644 index 000000000..1e713b461 --- /dev/null +++ b/modules/Cms/Services/CmsVisitorRedirectResolver.php @@ -0,0 +1,228 @@ +shouldExcludeRequest($request)) { + return null; + } + + [$locale, $pathKey, $explicitLocalePrefix] = $this->resolveLocalePathKeyAndExplicitFlag($request); + + if ($this->isActivePagePath($locale, $pathKey, $explicitLocalePrefix)) { + return null; + } + + $redirect = $this->findMatchingRedirect($locale, $pathKey); + if ($redirect === null) { + return null; + } + + return $this->toHttpRedirect($redirect); + } + + public function shouldExcludeRequest(Request $request): bool + { + $normalized = $this->canonicalUrlResolver->normalizePath($request->path()); + $previewPrefix = '/' . trim((string) modularityConfig('cms_routing.signed_preview.path_prefix', 'cms/preview'), '/'); + if (modularityConfig('cms_routing.signed_preview.enabled', true) + && $previewPrefix !== '/' + && ($normalized === $previewPrefix || str_starts_with($normalized, $previewPrefix . '/'))) { + return true; + } + + $first = explode('/', trim($normalized, '/'))[0] ?? ''; + + $extra = (array) modularityConfig('cms_routing.visitor_redirect_exclude_prefixes', ['api', 'sanctum', 'livewire']); + if ($first !== '' && in_array($first, $extra, true)) { + return true; + } + + $adminPrefix = Modularity::getAdminUrlPrefix(); + if ($adminPrefix !== false && $adminPrefix !== '') { + $p = '/' . ltrim((string) $adminPrefix, '/'); + if ($normalized === $p || str_starts_with($normalized, $p . '/')) { + return true; + } + } + + $system = (string) modularityConfig('system_prefix', 'system-settings'); + $system = '/' . trim(str_replace('_', '-', $system), '/'); + if ($normalized === $system || str_starts_with($normalized, $system . '/')) { + return true; + } + + return false; + } + + /** + * Resolves locale + registry path for public CMS URLs. + * + * When the matched route uses a dedicated locale route parameter plus a wildcard path (see + * {@see \Modules\Cms\Routing\CmsFrontRouteLocalizationBinding}), the path parameter is the full remainder after + * the locale — including ParentSegment prefixes and the slug, aligned with mcamara-style translated URL segments + * but driven by {@see \Modules\Cms\Entities\UrlRoute} and {@see \Modules\Cms\Services\CmsParentSegmentResolver} + * instead of static lang route files. + * + * @return array{0: string, 1: string, 2: bool} [locale, normalized inner path for UrlRoute / from_path match, had explicit locale segment] + */ + public function resolveLocalePathKeyAndExplicitFlag(Request $request): array + { + $route = $request->route(); + + if ($route !== null && $route->hasParameter('locale') && $route->hasParameter('path')) { + $locale = (string) $route->parameter('locale'); + $locales = $this->cmsLocalization->pathSegmentLocales(); + if (in_array($locale, $locales, true)) { + $tail = (string) $route->parameter('path'); + $pathKey = $this->canonicalUrlResolver->normalizePath('/' . ltrim($tail, '/')); + + return [$locale, $pathKey, true]; + } + } + + $inner = CmsFrontPath::innerNormalizedPath($request, $this->canonicalUrlResolver); + + return $this->resolveLocaleAndInnerPath($inner); + } + + /** + * @return array{0: string, 1: string, 2: bool} [locale, normalized inner path for UrlRoute / from_path match, had explicit locale segment] + */ + public function resolveLocaleAndInnerPath(string $normalizedPath): array + { + $locales = $this->cmsLocalization->pathSegmentLocales(); + + $default = CmsSluglessFallbackLocale::implicitPreferredLocaleOtherwise($this->cmsLocalization->defaultLocale()); + + foreach ($locales as $loc) { + $needle = '/' . trim($loc, '/'); + if ($normalizedPath === $needle) { + return [$loc, '/', true]; + } + if (str_starts_with($normalizedPath, $needle . '/')) { + $inner = mb_substr($normalizedPath, mb_strlen($needle)); + $innerNorm = $inner === '' || $inner === '/' + ? '/' + : $this->canonicalUrlResolver->normalizePath($inner); + + return [$loc, $innerNorm, true]; + } + } + + return [$default, $normalizedPath, false]; + } + + /** + * @param bool $innerHadExplicitLocale From {@see resolveLocaleAndInnerPath} — {@code false} means no {@code /{locale}/} + * prefix; then only an {@see UrlRoute} for the implicit editorial locale counts as an active page (consistent + * with {@see \Modules\Cms\Services\CmsPublicModelResolver}). + */ + public function isActivePagePath(string $locale, string $pathKey, bool $innerHadExplicitLocale): bool + { + if (! Schema::hasTable((new UrlRoute)->getTable())) { + return false; + } + + $variants = $this->canonicalUrlResolver->normalizedPathRegistryLookupVariants($pathKey); + + $query = UrlRoute::query() + ->whereIn('normalized_path', $variants) + ->where('kind', UrlRoute::KIND_PAGE_PUBLIC); + + if ($innerHadExplicitLocale) { + return $query->where('locale', $locale)->exists(); + } + + $implicitLocale = CmsSluglessFallbackLocale::implicitPreferredLocaleOtherwise($this->cmsLocalization->defaultLocale()); + $needle = mb_strtolower(trim($implicitLocale)); + + return $query->whereRaw('LOWER(TRIM(locale)) = ?', [$needle])->exists(); + } + + protected function findMatchingRedirect(string $locale, string $pathKey): ?Redirect + { + $variants = $this->canonicalUrlResolver->normalizedPathRegistryLookupVariants($pathKey); + + if (Schema::hasTable((new UrlRoute)->getTable())) { + $row = UrlRoute::query() + ->where('locale', $locale) + ->whereIn('normalized_path', $variants) + ->where('kind', UrlRoute::KIND_REDIRECT_SOURCE) + ->first(); + + if ($row !== null) { + $urlable = $row->urlable; + if ($urlable instanceof Redirect && $urlable->is_active) { + return $urlable; + } + } + } + + if (! Schema::hasTable((new Redirect)->getTable())) { + return null; + } + + return Redirect::query() + ->where('locale', $locale) + ->where('is_active', true) + ->get() + ->first(function (Redirect $r) use ($pathKey) { + return $this->canonicalUrlResolver->normalizePath((string) $r->from_path) === $pathKey; + }); + } + + protected function toHttpRedirect(Redirect $redirect): RedirectResponse + { + $code = (int) $redirect->status_code; + if ($code < 300 || $code > 399) { + $code = 302; + } + + $target = trim((string) $redirect->to_path); + + if ($target === '') { + return redirect()->to('/', $code); + } + + if (preg_match('#^https?://#i', $target)) { + return tap(redirect()->away($target), fn (RedirectResponse $r) => $r->setStatusCode($code)); + } + + if (! str_starts_with($target, '/')) { + $target = '/' . $target; + } + + return tap(redirect()->to($target), fn (RedirectResponse $r) => $r->setStatusCode($code)); + } +} diff --git a/modules/Cms/Services/Concerns/ExtendsSlugValidationWithPublicUrlRegistry.php b/modules/Cms/Services/Concerns/ExtendsSlugValidationWithPublicUrlRegistry.php new file mode 100644 index 000000000..a92c164c7 --- /dev/null +++ b/modules/Cms/Services/Concerns/ExtendsSlugValidationWithPublicUrlRegistry.php @@ -0,0 +1,190 @@ +applyPublicUrlRegistryToSlugValidation($result, $modelClass, $value, $locale, $excludeId); + } + + /** + * @param array{valid: bool, message: ?string, normalized: string} $result + * @return array{valid: bool, message: ?string, normalized: string, warnings: list} + */ + protected function applyPublicUrlRegistryToSlugValidation( + array $result, + string $modelClass, + string $value, + ?string $locale, + ?int $excludeId, + ): array { + $nested = $this->nestedPublicUrlRegistryWarnings($modelClass, $value, $locale, $excludeId); + $result['warnings'] = $nested; + + if (! ($result['valid'] ?? false)) { + return $result; + } + + if (! $this->slugModelUsesPublicUrlRegistry($modelClass)) { + return $result; + } + + if (! app()->bound(PublicUrlRegistryContract::class)) { + return $result; + } + + /** @var PublicUrlRegistryContract $registry */ + $registry = app(PublicUrlRegistryContract::class); + + if (! $registry->tableReady()) { + return $result; + } + + $localeStr = (string) ($locale ?? app()->getLocale()); + $path = $this->registryPathFromNormalizedSlug($modelClass, $result, $value, $localeStr); + if ($path === null || $path === '') { + return $result; + } + + [$morphClass, $id] = $this->registryUrlableMorphForSlugValidation($modelClass, $excludeId); + + if ($registry->isPathClaimedByOther($localeStr, $path, $morphClass, $id)) { + $result['valid'] = false; + $result['message'] = $this->publicUrlRegistryPathCollisionMessage(); + } + + return $result; + } + + /** + * Whether this model's slug is registered in {@see PublicUrlRegistryContract}. + */ + protected function slugModelUsesPublicUrlRegistry(string $modelClass): bool + { + return false; + } + + /** + * @return list + */ + protected function nestedPublicUrlRegistryWarnings( + string $modelClass, + string $value, + ?string $locale, + ?int $excludeId, + ): array { + if (! $this->slugModelUsesPublicUrlRegistry($modelClass)) { + return []; + } + + if (! $this->nestedPublicUrlRegistryWarningsEnabled()) { + return []; + } + + if (! app()->bound(PublicUrlRegistryContract::class)) { + return []; + } + + /** @var PublicUrlRegistryContract $registry */ + $registry = app(PublicUrlRegistryContract::class); + + if (! $registry->tableReady()) { + return []; + } + + $raw = trim($value); + if ($raw === '') { + return []; + } + + $localeStr = (string) ($locale ?? app()->getLocale()); + $path = $this->nestedWarningPathFromRawSlug($modelClass, $raw, $localeStr); + if ($path === null || $path === '') { + return []; + } + + $kind = $this->publicUrlRegistryRouteKindForNestedWarnings($modelClass); + $morph = $this->publicUrlRegistryMorphClassForNestedWarnings($modelClass); + if ($kind === null || $morph === null) { + return []; + } + + return $registry->nestedPathPrefixWarnings($localeStr, $path, $kind, $morph, $excludeId); + } + + /** + * Override when {@see nestedPublicUrlRegistryWarnings()} is used (e.g. modularity config flag). + */ + protected function nestedPublicUrlRegistryWarningsEnabled(): bool + { + return true; + } + + /** + * Normalized path for collision check from parent validation result. + * + * @param array{valid: bool, message: ?string, normalized: string} $parentResult + */ + protected function registryPathFromNormalizedSlug( + string $modelClass, + array $parentResult, + string $value, + string $locale, + ): ?string { + return '/' . ltrim((string) ($parentResult['normalized'] ?? ''), '/'); + } + + /** + * Raw input path for nested warnings (may differ from stored slug normalization in edge cases). + */ + protected function nestedWarningPathFromRawSlug(string $modelClass, string $rawValue, string $locale): ?string + { + return '/' . ltrim($rawValue, '/'); + } + + /** + * @return array{0: class-string<\Illuminate\Database\Eloquent\Model>|null, 1: int|null} + */ + protected function registryUrlableMorphForSlugValidation(string $modelClass, ?int $excludeId): array + { + return [$modelClass, $excludeId]; + } + + /** + * @return non-empty-string|null + */ + protected function publicUrlRegistryRouteKindForNestedWarnings(string $modelClass): ?string + { + return null; + } + + /** + * @return class-string<\Illuminate\Database\Eloquent\Model>|null + */ + protected function publicUrlRegistryMorphClassForNestedWarnings(string $modelClass): ?string + { + return null; + } + + protected function publicUrlRegistryPathCollisionMessage(): string + { + return __('This public URL path is already registered.'); + } +} diff --git a/modules/Cms/Services/DbFullTextSearchDriver.php b/modules/Cms/Services/DbFullTextSearchDriver.php new file mode 100644 index 000000000..6fedbd126 --- /dev/null +++ b/modules/Cms/Services/DbFullTextSearchDriver.php @@ -0,0 +1,49 @@ +searchIndexesTable())->updateOrInsert( + [ + 'entity_type' => $entityType, + 'entity_id' => (string) $entityId, + ], + [ + 'document' => json_encode($document), + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + } + + public function remove(string $entityType, int|string $entityId): void + { + DB::table($this->searchIndexesTable()) + ->where('entity_type', $entityType) + ->where('entity_id', (string) $entityId) + ->delete(); + } + + public function search(string $query, array $options = []): array + { + $limit = (int) ($options['limit'] ?? 20); + + return DB::table($this->searchIndexesTable()) + ->where('document', 'like', '%' . $query . '%') + ->limit($limit) + ->get() + ->map(fn ($row) => (array) $row) + ->toArray(); + } +} diff --git a/modules/Cms/Services/DefaultCmsPromotionScopeApplier.php b/modules/Cms/Services/DefaultCmsPromotionScopeApplier.php new file mode 100644 index 000000000..c74ba6f13 --- /dev/null +++ b/modules/Cms/Services/DefaultCmsPromotionScopeApplier.php @@ -0,0 +1,20 @@ + [], + 'skipped' => [], + ]; + } +} diff --git a/modules/Cms/Services/NullLeadDelivery.php b/modules/Cms/Services/NullLeadDelivery.php new file mode 100644 index 000000000..872d505c3 --- /dev/null +++ b/modules/Cms/Services/NullLeadDelivery.php @@ -0,0 +1,18 @@ + false, + 'driver' => 'null', + 'message' => 'CRM/Webhook integration is not enabled in v1.', + 'payload' => $leadPayload, + ]; + } +} diff --git a/modules/Cms/Services/RedirectValidationService.php b/modules/Cms/Services/RedirectValidationService.php new file mode 100644 index 000000000..4cc003096 --- /dev/null +++ b/modules/Cms/Services/RedirectValidationService.php @@ -0,0 +1,116 @@ +canonicalUrlResolver->normalizePath($fromPath); + $to = $this->canonicalUrlResolver->normalizePath($toPath); + + if (! empty($from) && ! empty($to) && $from === $to) { + $errors[] = 'Redirect cannot point to itself.'; + } + + $activePaths = $this->resolveActivePublicPaths($options); + + if (in_array($from, $activePaths, true)) { + $errors[] = 'Active page route takes precedence. Redirect source conflicts with an active page path.'; + } + + $existingRedirects = (array) ($options['existing_redirects'] ?? []); + + $graph = []; + foreach ($existingRedirects as $source => $target) { + $graph[$this->canonicalUrlResolver->normalizePath((string) $source)] = $this->canonicalUrlResolver->normalizePath((string) $target); + } + $graph[$from] = $to; + + if ($this->followPathDetectsCycle($graph, $from)) { + $errors[] = 'Redirect loop detected.'; + } + + if ($this->isCrossLocaleRedirect($from, $to)) { + $warnings[] = 'Cross-locale redirect detected. Verify locale strategy before publishing.'; + } + + return [ + 'valid' => count($errors) === 0, + 'errors' => $errors, + 'warnings' => $warnings, + 'normalized' => [ + 'from' => $from, + 'to' => $to, + ], + 'suggestion' => count($errors) === 0 ? null : $from . '-1', + ]; + } + + /** + * Prefer {@see CmsUrlRouteRegistry::activePublicPagePathsForLocale()} when `locale` is set; otherwise use + * precomputed `active_paths` (e.g. tests or callers without locale context). + * + * @param array{locale?: string, active_paths?: list|array} $options + * @return list + */ + protected function resolveActivePublicPaths(array $options): array + { + if (isset($options['locale'])) { + return $this->urlRouteRegistry->activePublicPagePathsForLocale((string) $options['locale']); + } + + return array_map( + fn ($path) => $this->canonicalUrlResolver->normalizePath((string) $path), + (array) ($options['active_paths'] ?? []) + ); + } + + /** + * Follow single-target edges from {@see $start} (the new/updated redirect source). + */ + protected function followPathDetectsCycle(array $graph, string $start): bool + { + $visited = []; + $current = $start; + + while (isset($graph[$current])) { + if (isset($visited[$current])) { + return true; + } + + $visited[$current] = true; + $current = $graph[$current]; + } + + return false; + } + + protected function isCrossLocaleRedirect(string $from, string $to): bool + { + $fromLocale = $this->firstSegment($from); + $toLocale = $this->firstSegment($to); + + $locales = CmsPathLocale::pathSegmentLocales(); + $fromIsLocale = in_array($fromLocale, $locales, true); + $toIsLocale = in_array($toLocale, $locales, true); + + return $fromIsLocale && $toIsLocale && $fromLocale !== $toLocale; + } + + protected function firstSegment(string $path): string + { + return explode('/', ltrim($path, '/'))[0] ?? ''; + } +} diff --git a/modules/Cms/Support/CmsFrontPath.php b/modules/Cms/Support/CmsFrontPath.php new file mode 100644 index 000000000..077f94a7a --- /dev/null +++ b/modules/Cms/Support/CmsFrontPath.php @@ -0,0 +1,95 @@ +normalizePath($request->path()); + $segment = trim((string) modularityConfig('cms_routing.front_route_prefix', 'cms'), '/'); + + if ($segment === '') { + return $full; + } + + $prefix = '/' . $segment; + + if ($full === $prefix) { + return '/'; + } + + if (str_starts_with($full, $prefix . '/')) { + $rest = mb_substr($full, mb_strlen($prefix)); + + return $canonical->normalizePath($rest); + } + + return $full; + } + + /** + * Inverse of {@see innerNormalizedPath()}: builds the browser path for a {@see \Modules\Cms\Entities\UrlRoute} + * `normalized_path` + locale (same rules as admin slug preview / public routing). + * + * @todo Consolidate CMS URL surface with a shared trait (HasParentSegment + HasSlug + UrlRoute) for non-Page entities. + */ + public static function publicBrowserPathForLocaleAndRegistryPath( + string $locale, + string $normalizedPath, + ?CanonicalUrlResolverInterface $canonical = null, + ): string { + $canonical ??= app(CanonicalUrlResolverInterface::class); + + $front = trim((string) modularityConfig('cms_routing.front_route_prefix', 'cms'), '/'); + $defaultLocale = (string) modularityConfig('cms_routing.default_locale', config('app.locale')); + $hideDefaultLocale = (bool) modularityConfig('cms_routing.hide_default_locale_segment', false); + + $locale = trim($locale, '/'); + $segments = []; + + if ($front !== '') { + $segments[] = $front; + } + + $appendLocaleSegment = true; + + if (CmsSluglessFallbackLocale::shouldOmitLocaleSegmentFromPublicUrlsFor($locale)) { + $appendLocaleSegment = false; + } elseif ($hideDefaultLocale && $locale === $defaultLocale) { + $appendLocaleSegment = false; + } + + if ($appendLocaleSegment && $locale !== '') { + $segments[] = $locale; + } + + $inner = trim($normalizedPath, '/'); + if ($inner !== '') { + foreach (explode('/', $inner) as $part) { + if ($part !== '') { + $segments[] = $part; + } + } + } + + $raw = '/' . implode('/', array_filter($segments, fn ($s) => $s !== '')); + + return $canonical->normalizePath($raw === '//' ? '/' : $raw); + } +} diff --git a/modules/Cms/Support/CmsParentSegmentRegistryGate.php b/modules/Cms/Support/CmsParentSegmentRegistryGate.php new file mode 100644 index 000000000..adeb8e92b --- /dev/null +++ b/modules/Cms/Support/CmsParentSegmentRegistryGate.php @@ -0,0 +1,56 @@ + $modelClass + */ + public static function allowsModelClass(string $modelClass): bool + { + if (! is_a($modelClass, Model::class, true)) { + return false; + } + + if (! classHasTrait($modelClass, \Modules\Cms\Entities\Concerns\HasParentSegment::class) + && ! classHasTrait($modelClass, \Unusualify\Modularity\Entities\Traits\IsSingular::class)) { + return false; + } + + return self::targetMatchesEnabledParentSegment($modelClass); + } + + /** + * @param class-string $modelClass + */ + private static function targetMatchesEnabledParentSegment(string $modelClass): bool + { + if (! Schema::hasTable((new ParentSegment)->getTable())) { + return false; + } + + $aliases = array_values(array_unique(array_filter([ + $modelClass, + (new $modelClass)->getMorphClass(), + ]))); + + return ParentSegment::query() + ->where('enabled', true) + ->whereIn('target_model_class', $aliases) + ->exists(); + } +} diff --git a/modules/Cms/Support/CmsPathLocale.php b/modules/Cms/Support/CmsPathLocale.php new file mode 100644 index 000000000..9834282c8 --- /dev/null +++ b/modules/Cms/Support/CmsPathLocale.php @@ -0,0 +1,90 @@ + + */ + public static function pathSegmentLocales(): array + { + if (! app()->bound(CmsLocalizationContract::class)) { + return self::legacyPathSegmentLocales(); + } + + return app(CmsLocalizationContract::class)->pathSegmentLocales(); + } + + /** + * When CMS localization is not registered (feature disabled / early boot). + * + * @return list + */ + private static function legacyPathSegmentLocales(): array + { + $configured = modularityConfig('cms_routing.path_segment_locales'); + if (is_array($configured) && $configured !== []) { + return self::sortedLongestFirst(array_values(array_unique(array_filter(array_map('strval', $configured))))); + } + + $mcamaraKeys = null; + + if (class_exists(\Mcamara\LaravelLocalization\Facades\LaravelLocalization::class)) { + try { + $mcamaraKeys = \Mcamara\LaravelLocalization\Facades\LaravelLocalization::getSupportedLanguagesKeys(); + if ($mcamaraKeys === []) { + $mcamaraKeys = null; + } + } catch (\Throwable) { + $mcamaraKeys = null; + } + } + + return self::mergeMcamaraKeysWithSiteLocales($mcamaraKeys); + } + + /** + * Locales mcamara exposes as route keys merged with CMS/translatable locales ({@see getLocales()}). + * Prevents {@code /tr/...} from being parsed under the default locale when Turkish exists only outside mcamara. + * + * @param list|null $mcamaraKeys + * @return list + */ + public static function mergeMcamaraKeysWithSiteLocales(?array $mcamaraKeys): array + { + $keys = []; + + if (is_array($mcamaraKeys)) { + foreach ($mcamaraKeys as $key) { + $key = trim((string) $key); + if ($key !== '') { + $keys[] = $key; + } + } + } + + foreach (getLocales() as $locale) { + $keys[] = (string) $locale; + } + + return self::sortedLongestFirst(array_values(array_unique(array_filter(array_map('strval', $keys))))); + } + + /** + * @param list $locales + * @return list + */ + private static function sortedLongestFirst(array $locales): array + { + usort($locales, fn (string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a)); + + return $locales; + } +} diff --git a/modules/Cms/Support/CmsPublicFrontViewName.php b/modules/Cms/Support/CmsPublicFrontViewName.php new file mode 100644 index 000000000..840208ecb --- /dev/null +++ b/modules/Cms/Support/CmsPublicFrontViewName.php @@ -0,0 +1,65 @@ +isModuleRouteClass() + ? $item->getModule() + : null; + if ($module !== null) { + foreach ($module->getRouteNames() as $routeName) { + if (! $module->isEnabledRoute($routeName)) { + continue; + } + try { + $m = $module->getModel($routeName, true); + } catch (\Throwable) { + continue; + } + if ($m::class === $class) { + return Str::snake($module->getName()) . '::' . Str::snake($routeName) . '.custom'; + } + } + } + + $fallback = (string) modularityConfig('cms_routing.universal_public_front_fallback_view', 'cms::page.custom'); + if ($fallback === '') { + return 'cms::page.custom'; + } + + return $fallback; + } + + private static function cmsModule(): ?Module + { + foreach (Modularity::allEnabled() as $module) { + if ($module instanceof Module && $module->getName() === 'Cms') { + return $module; + } + } + + return null; + } +} diff --git a/modules/Cms/Support/CmsPublicPathHierarchy.php b/modules/Cms/Support/CmsPublicPathHierarchy.php new file mode 100644 index 000000000..38bedd9dc --- /dev/null +++ b/modules/Cms/Support/CmsPublicPathHierarchy.php @@ -0,0 +1,28 @@ +seo_title + ?? optional($translation)->title + ?? 'Page'); + + $description = optional($translation)->seo_description; + $description = $description !== null && $description !== '' ? (string) $description : null; + + $canonicalUrl = self::resolveCanonical($request, $translation, $canonical); + + $robotsMeta = self::robotsDirective( + isset($translation) ? $translation->robots_index : null, + isset($translation) ? $translation->robots_follow : null, + ); + + return [ + 'title' => $title, + 'description' => $description, + 'canonicalUrl' => $canonicalUrl, + 'robotsMeta' => $robotsMeta, + ]; + } + + private static function resolveCanonical(Request $request, ?object $translation, CanonicalUrlResolverInterface $canonical): string + { + $custom = isset($translation) ? trim((string) ($translation->canonical_url ?? '')) : ''; + + if ($custom !== '') { + if (preg_match('#^https?://#i', $custom)) { + return $custom; + } + + return rtrim($request->getSchemeAndHttpHost(), '/') . '/' . ltrim($custom, '/'); + } + + $resolved = $canonical->resolve( + $request->getHost(), + $request->getPathInfo() ?: '/', + app()->getLocale(), + ['redirect_to_canonical' => false] + ); + + return $resolved['canonical_url'] ?? $request->url(); + } + + /** + * Laravel validation often omits unchecked bools; treat null as "allow" (index/follow). + */ + private static function robotsDirective(mixed $index, mixed $follow): string + { + $noIndex = $index === false; + $noFollow = $follow === false; + + $indexPart = $noIndex ? 'noindex' : 'index'; + $followPart = $noFollow ? 'nofollow' : 'follow'; + + return $indexPart . ', ' . $followPart; + } +} diff --git a/modules/Cms/Support/CmsPublicSiteUrl.php b/modules/Cms/Support/CmsPublicSiteUrl.php new file mode 100644 index 000000000..1520b3499 --- /dev/null +++ b/modules/Cms/Support/CmsPublicSiteUrl.php @@ -0,0 +1,73 @@ + 399) { + return 301; + } + + return $code; + } +} diff --git a/modules/Cms/Support/ParentSegmentBindingValidator.php b/modules/Cms/Support/ParentSegmentBindingValidator.php new file mode 100644 index 000000000..9335ccc61 --- /dev/null +++ b/modules/Cms/Support/ParentSegmentBindingValidator.php @@ -0,0 +1,79 @@ +first(function (ParentSegment $row) use ($targetModelClass, $localeScope): bool { + return (string) $row->target_model_class !== (string) $targetModelClass + && static::localeScopesOverlap($localeScope, (string) ($row->locale ?? '')); + }); + + if ($conflicting !== null) { + throw ValidationException::withMessages([ + 'normalized_prefix' => [ + __('CMS already has a homepage binding (empty path prefix) for this locale scope on another module model (ID :id).', [ + 'id' => $conflicting->getKey(), + ]), + ], + ]); + } + } + + /** @return Collection */ + public static function enabledBindingsWithEffectivelyEmptyPrefix(?int $exceptId = null): Collection + { + $query = ParentSegment::query() + ->where('enabled', true) + ->when($exceptId !== null, fn ($q) => $q->whereKeyNot($exceptId)); + + return $query->get()->filter( + fn (ParentSegment $row): bool => static::isWhitespaceOnlyEmptyPrefix((string) ($row->normalized_prefix ?? '')) + )->values(); + } +} diff --git a/modules/Cms/Transformers/.gitkeep b/modules/Cms/Transformers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/modules/Cms/Transformers/HomepageTestResource.php b/modules/Cms/Transformers/HomepageTestResource.php new file mode 100644 index 000000000..91f8b6233 --- /dev/null +++ b/modules/Cms/Transformers/HomepageTestResource.php @@ -0,0 +1,16 @@ + [], ], ], + 'capability' => [ + 'name' => 'Capability', + 'headline' => 'Capabilities', + 'url' => 'capabilities', + 'route_name' => 'capability', + 'icon' => 'mdi-shield-lock-outline', + 'index_with' => [ + 'roles', + 'routes', + ], + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + 'isRowEditing' => true, + 'rowActionsType' => 'inline', + ], + 'headers' => [ + [ + 'title' => 'Title', + 'key' => 'title', + 'sortable' => true, + 'searchable' => true, + ], + [ + 'title' => 'Name', + 'key' => 'name', + 'sortable' => true, + 'searchable' => true, + ], + [ + 'title' => 'Roles', + 'key' => 'roles', + 'itemTitle' => 'title', + ], + [ + 'title' => 'Routes', + 'key' => 'routes', + 'itemTitle' => 'route_name', + ], + [ + 'title' => 'Strict Route Binding', + 'key' => 'strict_route_binding', + 'formatter' => [ + 'switch', + ], + ], + [ + 'title' => 'Step-Up', + 'key' => 'requires_step_up', + 'formatter' => [ + 'switch', + ], + ], + [ + 'title' => 'Actions', + 'key' => 'actions', + 'sortable' => false, + ], + ], + 'inputs' => [ + [ + 'type' => 'text', + 'name' => 'name', + 'label' => 'Capability Key', + 'placeholder' => 'promotion.execute', + 'rules' => 'required|min:3', + 'col' => [ + 'cols' => 12, + 'sm' => 8, + 'md' => 6, + ], + ], + [ + 'type' => 'text', + 'name' => 'title', + 'label' => 'Title', + 'placeholder' => 'Promotion Execute', + 'col' => [ + 'cols' => 12, + 'sm' => 8, + 'md' => 6, + ], + ], + [ + 'type' => 'select', + 'name' => 'roles', + 'multiple' => true, + 'itemValue' => 'id', + 'itemTitle' => 'title', + 'label' => 'Allowed Roles', + 'connector' => 'SystemUser:Role|repository:list:column=title', + 'col' => [ + 'cols' => 12, + ], + ], + [ + 'type' => 'select-scroll', + 'componentType' => 'v-autocomplete', + 'name' => 'routes', + 'label' => 'Bound Routes', + 'multiple' => true, + 'chips' => true, + 'itemValue' => 'id', + 'itemTitle' => 'route_name', + 'itemsPerPage' => 100, + 'page' => 1, + 'endpoint' => 'admin.system.system_user.capability_route.index', + 'searchKeys' => ['route_name'], + 'col' => [ + 'cols' => 12, + ], + ], + [ + 'type' => 'switch', + 'name' => 'strict_route_binding', + 'label' => 'Strict Route Binding', + 'default' => false, + 'hint' => 'When enabled, step-up runs only for bound route names.', + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + 'lg' => 3, + ], + ], + [ + 'type' => 'switch', + 'name' => 'requires_step_up', + 'label' => 'Require Step-Up', + 'default' => false, + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + 'lg' => 3, + ], + ], + [ + 'type' => 'switch', + 'name' => 'published', + 'label' => 'Active', + 'default' => true, + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + 'lg' => 3, + ], + ], + ], + ], + 'capability_route' => [ + 'name' => 'CapabilityRoute', + 'headline' => 'Capability Routes', + 'url' => 'capability-routes', + 'route_name' => 'capability_route', + // 'belongs' => ['capability'], + 'icon' => 'mdi-security-network', + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + 'isRowEditing' => true, + 'rowActionsType' => 'inline', + ], + 'headers' => [ + [ + 'title' => 'Route Name', + 'key' => 'route_name', + 'searchable' => true, + ], + [ + 'title' => 'Active', + 'key' => 'is_active', + 'formatter' => [ + 'switch', + ], + ], + [ + 'title' => 'Actions', + 'key' => 'actions', + 'sortable' => false, + ], + ], + 'inputs' => [ + [ + 'type' => 'select-scroll', + 'componentType' => 'v-autocomplete', + 'name' => 'route_name', + 'label' => 'Route Name', + 'itemValue' => 'name', + 'itemTitle' => 'name_with_uri', + 'placeholder' => 'admin.system.cms.promotion.execute', + 'itemsPerPage' => 100, + 'page' => 1, + 'endpoint' => 'admin.system.system_user.capabilities.discover_routes', + 'searchKeys' => ['name', 'uri'], + 'col' => [ + 'cols' => 12, + ], + 'rules' => 'required|min:2', + ], + [ + 'type' => 'switch', + 'name' => 'is_active', + 'label' => 'Active', + 'default' => true, + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + ], + ], + ], + ], 'company' => [ 'name' => 'Company', 'headline' => 'Companies', diff --git a/modules/SystemUser/Database/Migrations/2026_03_30_000001_create_system_user_capabilities_table.php b/modules/SystemUser/Database/Migrations/2026_03_30_000001_create_system_user_capabilities_table.php new file mode 100644 index 000000000..8bda75006 --- /dev/null +++ b/modules/SystemUser/Database/Migrations/2026_03_30_000001_create_system_user_capabilities_table.php @@ -0,0 +1,78 @@ +id(); + $table->string('name')->unique(); + $table->string('title')->nullable(); + $table->boolean('strict_route_binding')->default(false); + $table->boolean('requires_step_up')->default(false); + $table->boolean('published')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } elseif (! Schema::hasColumn($capabilitiesTable, 'strict_route_binding')) { + Schema::table($capabilitiesTable, function (Blueprint $table) { + $table->boolean('strict_route_binding')->default(false)->after('title'); + }); + } + + if (! Schema::hasTable($pivotTable)) { + Schema::create($pivotTable, function (Blueprint $table) use ($capabilitiesTable, $rolesTable) { + $table->id(); + $table->unsignedBigInteger('role_id'); + $table->unsignedBigInteger('capability_id'); + $table->timestamps(); + + $table->unique(['role_id', 'capability_id'], 'um_role_capability_unique'); + $table->foreign('role_id')->references('id')->on($rolesTable)->cascadeOnDelete(); + $table->foreign('capability_id')->references('id')->on($capabilitiesTable)->cascadeOnDelete(); + }); + } + + if (! Schema::hasTable($capabilityRoutesTable)) { + Schema::create($capabilityRoutesTable, function (Blueprint $table) { + $table->id(); + $table->string('route_name')->unique(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + if (! Schema::hasTable($capabilityRoutePivotTable)) { + Schema::create($capabilityRoutePivotTable, function (Blueprint $table) use ($capabilitiesTable, $capabilityRoutesTable) { + $table->id(); + $table->unsignedBigInteger('capability_id'); + $table->unsignedBigInteger('capability_route_id'); + $table->timestamps(); + + $table->unique(['capability_id', 'capability_route_id'], 'um_capability_capability_route_unique'); + $table->foreign('capability_id')->references('id')->on($capabilitiesTable)->cascadeOnDelete(); + $table->foreign('capability_route_id')->references('id')->on($capabilityRoutesTable)->cascadeOnDelete(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists(modularityConfig('tables.capability_capability_route', 'um_capability_capability_route')); + Schema::dropIfExists(modularityConfig('tables.capability_routes', 'um_capability_routes')); + Schema::dropIfExists(modularityConfig('tables.role_capability', 'um_role_capability')); + Schema::dropIfExists(modularityConfig('tables.capabilities', 'um_capabilities')); + } +}; diff --git a/modules/SystemUser/Entities/Capability.php b/modules/SystemUser/Entities/Capability.php new file mode 100644 index 000000000..4083cc22e --- /dev/null +++ b/modules/SystemUser/Entities/Capability.php @@ -0,0 +1,51 @@ + 'boolean', + // 'requires_step_up' => 'boolean', + // 'published' => 'boolean', + // ]; + + public function getTable() + { + return modularityConfig('tables.capabilities', parent::getTable()); + } + + public function roles(): BelongsToMany + { + return $this->belongsToMany( + Role::class, + modularityConfig('tables.role_capability', 'um_role_capability'), + 'capability_id', + 'role_id' + ); + } + + public function routes(): BelongsToMany + { + return $this->belongsToMany( + CapabilityRoute::class, + modularityConfig('tables.capability_capability_route', 'um_capability_capability_route'), + 'capability_id', + 'capability_route_id' + ); + } +} diff --git a/modules/SystemUser/Entities/CapabilityRoute.php b/modules/SystemUser/Entities/CapabilityRoute.php new file mode 100644 index 000000000..25f0dd2ff --- /dev/null +++ b/modules/SystemUser/Entities/CapabilityRoute.php @@ -0,0 +1,37 @@ + 'boolean', + // ]; + + public function getTable() + { + return modularityConfig('tables.capability_routes', parent::getTable()); + } + + public function capabilities(): BelongsToMany + { + return $this->belongsToMany( + Capability::class, + modularityConfig('tables.capability_capability_route', 'um_capability_capability_route'), + 'capability_route_id', + 'capability_id' + ); + } +} diff --git a/modules/SystemUser/Entities/Role.php b/modules/SystemUser/Entities/Role.php index 765dd376b..2f1bb146e 100644 --- a/modules/SystemUser/Entities/Role.php +++ b/modules/SystemUser/Entities/Role.php @@ -2,15 +2,27 @@ namespace Modules\SystemUser\Entities; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Modules\SystemUser\Entities\Traits\FlushesSecurityCache; use Spatie\Permission\Models\Role as SpatieRole; use Unusualify\Modularity\Entities\Traits\Core\ModelHelpers; class Role extends SpatieRole { - use ModelHelpers; + use ModelHelpers, FlushesSecurityCache; public function scopeClient($query) { return $query->where('name', 'like', '%client%'); } + + public function capabilities(): BelongsToMany + { + return $this->belongsToMany( + Capability::class, + modularityConfig('tables.role_capability', 'um_role_capability'), + 'role_id', + 'capability_id' + ); + } } diff --git a/modules/SystemUser/Entities/Traits/FlushesSecurityCache.php b/modules/SystemUser/Entities/Traits/FlushesSecurityCache.php new file mode 100644 index 000000000..21fe6cd54 --- /dev/null +++ b/modules/SystemUser/Entities/Traits/FlushesSecurityCache.php @@ -0,0 +1,22 @@ +flushPersistentCache(); + }; + + static::saved($flush); + static::deleted($flush); + + if (method_exists(static::class, 'restored')) { + static::restored($flush); + } + } +} diff --git a/modules/SystemUser/Http/Controllers/API/CapabilityRouteDiscoveryController.php b/modules/SystemUser/Http/Controllers/API/CapabilityRouteDiscoveryController.php new file mode 100644 index 000000000..ef2d0dae0 --- /dev/null +++ b/modules/SystemUser/Http/Controllers/API/CapabilityRouteDiscoveryController.php @@ -0,0 +1,77 @@ +query('search') ?? $request->query('q', '')); + $onlyNamed = (bool) $request->boolean('only_named', true); + $perPage = max(1, min((int) $request->query('itemsPerPage', $request->query('limit', 50)), 300)); + $page = max(1, (int) $request->query('page', 1)); + + $routes = collect(Route::getRoutes())->map(function ($route) { + $methods = array_values(array_filter( + $route->methods(), + fn ($method) => ! in_array($method, ['HEAD', 'OPTIONS'], true) + )); + + return [ + 'name' => $route->getName(), + 'route_name' => $route->getName(), + 'uri' => $route->uri(), + 'method' => $methods[0] ?? null, + 'methods' => $methods, + 'name_with_uri' => $route->uri() . ' - ' . $route->getName(), + ]; + }); + + if ($onlyNamed) { + $routes = $routes->filter(fn ($item) => is_string($item['name']) && $item['name'] !== ''); + } + + if ($search !== '') { + $routes = $routes->filter(function ($item) use ($search) { + return str_contains((string) ($item['name'] ?? ''), $search) + || str_contains((string) ($item['uri'] ?? ''), $search); + }); + } + + $items = $routes + ->sortBy(fn ($item) => (string) ($item['name'] ?? $item['uri'])) + ->values(); + + $paginated = $this->paginate($items, $page, $perPage); + + return response()->json([ + 'resource' => [ + 'data' => $paginated['data'], + 'current_page' => $paginated['current_page'], + 'last_page' => $paginated['last_page'], + 'per_page' => $perPage, + 'total' => $paginated['total'], + ], + ]); + } + + private function paginate(Collection $items, int $page, int $perPage): array + { + $total = $items->count(); + $lastPage = max((int) ceil($total / $perPage), 1); + $page = min($page, $lastPage); + + return [ + 'data' => $items->forPage($page, $perPage)->values()->all(), + 'current_page' => $page, + 'last_page' => $lastPage, + 'total' => $total, + ]; + } +} diff --git a/modules/SystemUser/Http/Controllers/CapabilityController.php b/modules/SystemUser/Http/Controllers/CapabilityController.php new file mode 100644 index 000000000..a77b239cf --- /dev/null +++ b/modules/SystemUser/Http/Controllers/CapabilityController.php @@ -0,0 +1,29 @@ + ['sometimes', 'required', 'array'], + 'routes' => ['sometimes', 'array'], + 'strict_route_binding' => ['nullable', 'boolean'], + 'requires_step_up' => ['nullable', 'boolean'], + ]; + } + + public function rulesForCreate() + { + $tableName = $this->model->getTable(); + + return [ + 'name' => "required|string|min:3|unique:{$tableName},name", + 'routes' => ['sometimes', 'array'], + ]; + } + + public function rulesForUpdate() + { + $tableName = $this->model->getTable(); + + return [ + 'name' => "sometimes|required|string|min:3|unique:{$tableName},name,{$this->id}", + 'roles' => ['sometimes', 'required', 'array'], + 'routes' => ['sometimes', 'array'], + ]; + } +} diff --git a/modules/SystemUser/Http/Requests/CapabilityRouteRequest.php b/modules/SystemUser/Http/Requests/CapabilityRouteRequest.php new file mode 100644 index 000000000..4036cbeb4 --- /dev/null +++ b/modules/SystemUser/Http/Requests/CapabilityRouteRequest.php @@ -0,0 +1,53 @@ +model->getTable(); + + return [ + 'route_name' => [ + "required", + "string", + "min:2", + "unique:{$tableName},route_name", + function (string $attribute, mixed $value, Closure $fail) { + if (! is_string($value) || ! Route::has($value)) { + $fail(__('The selected route name must be a named Laravel route.')); + } + }, + ], + ]; + } + + public function rulesForUpdate() + { + $tableName = $this->model->getTable(); + + return [ + 'route_name' => [ + "required", + "string", + "min:2", + "unique:{$tableName},route_name,{$this->id}", + function (string $attribute, mixed $value, Closure $fail) { + if (! is_string($value) || ! Route::has($value)) { + $fail(__('The selected route name must be a named Laravel route.')); + } + }, + ], + ]; + } +} diff --git a/modules/SystemUser/Repositories/CapabilityRepository.php b/modules/SystemUser/Repositories/CapabilityRepository.php new file mode 100644 index 000000000..b0082be33 --- /dev/null +++ b/modules/SystemUser/Repositories/CapabilityRepository.php @@ -0,0 +1,15 @@ +model = $model; + } +} + diff --git a/modules/SystemUser/Repositories/CapabilityRouteRepository.php b/modules/SystemUser/Repositories/CapabilityRouteRepository.php new file mode 100644 index 000000000..496c57ed3 --- /dev/null +++ b/modules/SystemUser/Repositories/CapabilityRouteRepository.php @@ -0,0 +1,15 @@ +model = $model; + } +} + diff --git a/modules/SystemUser/Routes/api.php b/modules/SystemUser/Routes/api.php index 236ca07b5..22b41cd54 100755 --- a/modules/SystemUser/Routes/api.php +++ b/modules/SystemUser/Routes/api.php @@ -14,4 +14,5 @@ | */ -Route::middleware(['api.auth', ...ModularityRoutes::defaultMiddlewares()])->group(function () {}); +Route::middleware(['api.auth', ...ModularityRoutes::defaultMiddlewares()])->group(function () { +}); diff --git a/modules/SystemUser/Routes/web.php b/modules/SystemUser/Routes/web.php index 3e4b4bd90..afdec204e 100755 --- a/modules/SystemUser/Routes/web.php +++ b/modules/SystemUser/Routes/web.php @@ -1,6 +1,7 @@ group(function () { - Route::middleware((ModularityRoutes::defaultPanelMiddlewares()))->group(function () {}); + Route::middleware((ModularityRoutes::defaultPanelMiddlewares()))->group(function () { + Route::get('capabilities/discover-routes', [CapabilityRouteDiscoveryController::class, 'index']) + ->name('capabilities.discover_routes'); + }); }); diff --git a/phpunit.docs.xml b/phpunit.docs.xml new file mode 100644 index 000000000..de979c8f1 --- /dev/null +++ b/phpunit.docs.xml @@ -0,0 +1,39 @@ + + + + + tests/Docs + + + + + + + + + + + src + + + src/Contracts + src/Console + src/Database/migrations + tests + vendor + src/bootstrap.php + + + + + + + + + + + diff --git a/phpunit.xml b/phpunit.xml index 1378e7b58..e634994ac 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,7 @@ tests/Console tests/Generated + tests/Docs diff --git a/resources/views/auth/step-up-replay.blade.php b/resources/views/auth/step-up-replay.blade.php new file mode 100644 index 000000000..e1c96d533 --- /dev/null +++ b/resources/views/auth/step-up-replay.blade.php @@ -0,0 +1,53 @@ +@extends("{$MODULARITY_VIEW_NAMESPACE}::layouts.base") + +@section('body') + @php + $pendingRequest = $pendingRequest ?? []; + $payload = $pendingRequest['payload'] ?? []; + $method = strtoupper($pendingRequest['method'] ?? 'POST'); + $targetUrl = $pendingRequest['url'] ?? url()->previous(); + + $renderFields = function ($items, $prefix = null) use (&$renderFields) { + $html = ''; + + foreach ($items as $key => $value) { + $name = $prefix ? "{$prefix}[{$key}]" : $key; + + if (is_array($value)) { + $html .= $renderFields($value, $name); + continue; + } + + $html .= ''; + } + + return $html; + }; + @endphp + +
+
+

{{ __('Continuing your action') }}

+

{{ __('Your verification succeeded. We are continuing your previous request.') }}

+
+ @csrf + @if(! in_array($method, ['GET', 'POST'], true)) + + @endif + {!! $renderFields($payload) !!} + +
+
+
+@endsection + +@push('footer_js') + +@endpush diff --git a/routes/api.php b/routes/api.php index de0d8f9e4..15424b16b 100755 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,8 @@ name('inputs.slug.validate'); + +Route::post('inputs/slug/generate', SlugInputGenerateController::class) + ->name('inputs.slug.generate'); + // Route::group(['as' => 'api.', 'namespace' => 'API'], function(){ // Route::apiResource('languages', LanguageController::class, ['only' => 'index']); // }); diff --git a/routes/auth.php b/routes/auth.php index 10a862e7a..1c4a92a1e 100755 --- a/routes/auth.php +++ b/routes/auth.php @@ -14,16 +14,28 @@ */ if (modularityConfig('enabled.users-management')) { + $securityEnabled = (bool) modularityConfig('security.enabled', false); + $authMfaEnabled = (bool) modularityConfig('security.mfa.enabled', false); + $loginMiddlewares = $securityEnabled + ? ['throttle:' . modularityConfig('security.throttle.login', '8,1')] + : []; + $login2faMiddlewares = $authMfaEnabled + ? ['throttle:' . modularityConfig('security.mfa.throttle', modularityConfig('security.throttle.login_2fa', '6,1'))] + : []; + Route::get('register', 'RegisterController@showForm')->name('register.form'); Route::post('register', 'RegisterController@register')->name('register'); Route::get('login', 'LoginController@showForm')->name('login.form'); - Route::post('login', 'LoginController@login')->name('login'); + Route::post('login', 'LoginController@login')->middleware($loginMiddlewares)->name('login'); Route::post('logout', 'LoginController@logout')->name('logout'); Route::get('login/2fa', 'LoginController@showLogin2FaForm')->name('login-2fa.form'); - Route::post('login/2fa', 'LoginController@login2Fa')->name('login-2fa'); + Route::post('login/2fa', 'LoginController@login2Fa')->middleware($login2faMiddlewares)->name('login-2fa'); + Route::get('step-up', 'StepUpController@showForm')->name('step-up.form'); + Route::match(['get', 'post'], 'step-up/resend', 'StepUpController@resend')->middleware($login2faMiddlewares)->name('step-up.resend'); + Route::post('step-up/verify', 'StepUpController@verify')->middleware($login2faMiddlewares)->name('step-up.verify'); Route::get('login/oauth', 'LoginController@showPasswordForm')->name('login.oauth.showPasswordForm'); Route::post('login/oauth', 'LoginController@linkProvider')->name('login.oauth.linkProvider'); diff --git a/routes/front.php b/routes/front.php index e7ed740d2..35e6a5a98 100644 --- a/routes/front.php +++ b/routes/front.php @@ -3,9 +3,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Unusualify\Modularity\Http\Controllers\API\LanguageController; -use Unusualify\Modularity\Http\Controllers\ChatableController; -use Unusualify\Modularity\Http\Controllers\CurrencyExchangeController; -use Unusualify\Modularity\Http\Controllers\FilepondController; +use Unusualify\Modularity\Http\Controllers\Utility\CurrencyExchangeController; +use Unusualify\Modularity\Http\Controllers\Utility\FilepondController; /* |-------------------------------------------------------------------------- @@ -48,6 +47,9 @@ }); }); -Route::get('/', function (Request $request) { - return redirect()->route(Route::hasAdmin('login.form')); -})->name('modularity.home'); +if (!modularityConfig('cms.enabled')) { + Route::get('/', function (Request $request) { + return redirect()->route(Route::hasAdmin('login.form')); + })->name('modularity.home'); +} + diff --git a/routes/web.php b/routes/web.php index 19ae99757..7c3b081e6 100755 --- a/routes/web.php +++ b/routes/web.php @@ -3,9 +3,9 @@ use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Session; -use Unusualify\Modularity\Http\Controllers\ChatController; -use Unusualify\Modularity\Http\Controllers\ProcessController; -use Unusualify\Modularity\Http\Controllers\TagController; +use Unusualify\Modularity\Http\Controllers\Utility\ChatController; +use Unusualify\Modularity\Http\Controllers\Utility\ProcessController; +use Unusualify\Modularity\Http\Controllers\Utility\TagController; /* |-------------------------------------------------------------------------- @@ -40,12 +40,12 @@ Route::singleton('profile', 'ProfileController', ['names' => ['edit' => 'profile']]); Route::put('profile/company', 'ProfileController@updateCompany')->name('profile.company'); -Route::put('profile/ui-preferences', 'UIPreferencesController@update')->name('profile.ui-preferences'); +Route::put('profile/ui-preferences', 'Utility\UIPreferencesController@update')->name('profile.ui-preferences'); Route::resource('', 'DashboardController', ['as' => 'dashboard', 'names' => ['index' => 'dashboard']])->only(['index']); -Route::get('users/impersonate/stop', 'ImpersonateController@stopImpersonate')->name('impersonate.stop'); -Route::get('users/impersonate/{id}', 'ImpersonateController@impersonate')->name('impersonate'); +Route::get('users/impersonate/stop', 'Utility\ImpersonateController@stopImpersonate')->name('impersonate.stop'); +Route::get('users/impersonate/{id}', 'Utility\ImpersonateController@impersonate')->name('impersonate'); // system internal api routes (for ajax web routes) Route::prefix('api')->group(function () { @@ -83,27 +83,27 @@ if (modularityConfig('enabled.media-library')) { Route::group(['prefix' => 'media-library', 'as' => 'media-library.'], function () { - Route::post('sign-s3-upload', ['as' => 'sign-s3-upload', 'uses' => 'MediaLibraryController@signS3Upload']); - Route::get('sign-azure-upload', ['as' => 'sign-azure-upload', 'uses' => 'MediaLibraryController@signAzureUpload']); - Route::put('medias/single-update', ['as' => 'media.single-update', 'uses' => 'MediaLibraryController@singleUpdate']); - Route::put('medias/bulk-update', ['as' => 'media.bulk-update', 'uses' => 'MediaLibraryController@bulkUpdate']); - Route::put('medias/bulk-delete', ['as' => 'media.bulk-delete', 'uses' => 'MediaLibraryController@bulkDelete']); - Route::get('medias/tags', ['as' => 'media.tags', 'uses' => 'MediaLibraryController@tags']); - Route::resource('medias', 'MediaLibraryController', ['names' => 'media', 'only' => ['index', 'store', 'destroy']]); + Route::post('sign-s3-upload', ['as' => 'sign-s3-upload', 'uses' => 'Utility\MediaLibraryController@signS3Upload']); + Route::get('sign-azure-upload', ['as' => 'sign-azure-upload', 'uses' => 'Utility\MediaLibraryController@signAzureUpload']); + Route::put('medias/single-update', ['as' => 'media.single-update', 'uses' => 'Utility\MediaLibraryController@singleUpdate']); + Route::put('medias/bulk-update', ['as' => 'media.bulk-update', 'uses' => 'Utility\MediaLibraryController@bulkUpdate']); + Route::put('medias/bulk-delete', ['as' => 'media.bulk-delete', 'uses' => 'Utility\MediaLibraryController@bulkDelete']); + Route::get('medias/tags', ['as' => 'media.tags', 'uses' => 'Utility\MediaLibraryController@tags']); + Route::resource('medias', 'Utility\MediaLibraryController', ['names' => 'media', 'only' => ['index', 'store', 'destroy']]); }); } if (modularityConfig('enabled.file-library')) { Route::group(['prefix' => 'file-library', 'as' => 'file-library.'], function () { - Route::post('sign-s3-upload', ['as' => 'sign-s3-upload', 'uses' => 'FileLibraryController@signS3Upload']); - Route::get('sign-azure-upload', ['as' => 'sign-azure-upload', 'uses' => 'FileLibraryController@signAzureUpload']); - Route::put('files/single-update', ['as' => 'file.single-update', 'uses' => 'FileLibraryController@singleUpdate']); - Route::put('files/bulk-update', ['as' => 'file.bulk-update', 'uses' => 'FileLibraryController@bulkUpdate']); - Route::put('files/bulk-delete', ['as' => 'file.bulk-delete', 'uses' => 'FileLibraryController@bulkDelete']); - Route::get('files/tags', ['as' => 'file.tags', 'uses' => 'FileLibraryController@tags']); - Route::resource('files', 'FileLibraryController', ['names' => 'file', 'only' => ['index', 'store', 'destroy']]); + Route::post('sign-s3-upload', ['as' => 'sign-s3-upload', 'uses' => 'Utility\FileLibraryController@signS3Upload']); + Route::get('sign-azure-upload', ['as' => 'sign-azure-upload', 'uses' => 'Utility\FileLibraryController@signAzureUpload']); + Route::put('files/single-update', ['as' => 'file.single-update', 'uses' => 'Utility\FileLibraryController@singleUpdate']); + Route::put('files/bulk-update', ['as' => 'file.bulk-update', 'uses' => 'Utility\FileLibraryController@bulkUpdate']); + Route::put('files/bulk-delete', ['as' => 'file.bulk-delete', 'uses' => 'Utility\FileLibraryController@bulkDelete']); + Route::get('files/tags', ['as' => 'file.tags', 'uses' => 'Utility\FileLibraryController@tags']); + Route::resource('files', 'Utility\FileLibraryController', ['names' => 'file', 'only' => ['index', 'store', 'destroy']]); }); } }); -Route::post('modularity/metrics', 'MetricController')->name('modularity.metrics'); +Route::post('modularity/metrics', 'Utility\MetricController')->name('modularity.metrics'); diff --git a/src/Console/Docs/DocsAuditCommand.php b/src/Console/Docs/DocsAuditCommand.php new file mode 100644 index 000000000..22ffadf15 --- /dev/null +++ b/src/Console/Docs/DocsAuditCommand.php @@ -0,0 +1,332 @@ + 'Entities', + 'source' => 'src/Entities', + 'docs' => 'docs/src/pages/system-reference/backend/entities', + 'recursive' => false, + 'exclude_dirs' => ['Enums', 'Scopes', 'Traits', 'Translations', 'Casts', 'Observers', 'Mutators'], + ], + [ + 'label' => 'Entity Enums', + 'source' => 'src/Entities/Enums', + 'docs' => 'docs/src/pages/system-reference/backend/entity-enums', + ], + [ + 'label' => 'Entity Scopes', + 'source' => 'src/Entities/Scopes', + 'docs' => 'docs/src/pages/system-reference/backend/entity-scopes', + ], + [ + 'label' => 'Entity Traits', + 'source' => 'src/Entities/Traits', + 'docs' => 'docs/src/pages/system-reference/backend/entity-traits', + 'recursive' => true, + ], + [ + 'label' => 'Controllers', + 'source' => 'src/Http/Controllers', + 'docs' => 'docs/src/pages/system-reference/backend/controllers', + 'recursive' => true, + 'exclude_dirs' => ['Traits'], + ], + [ + 'label' => 'Middleware', + 'source' => 'src/Http/Middleware', + 'docs' => 'docs/src/pages/system-reference/backend/middleware', + ], + [ + 'label' => 'HTTP Requests', + 'source' => 'src/Http/Requests', + 'docs' => 'docs/src/pages/system-reference/backend/http-requests', + ], + [ + 'label' => 'View Composers', + 'source' => 'src/Http/ViewComposers', + 'docs' => 'docs/src/pages/system-reference/backend/view-composers', + ], + [ + 'label' => 'Facades', + 'source' => 'src/Facades', + 'docs' => 'docs/src/pages/system-reference/backend/facades', + ], + [ + 'label' => 'Helpers', + 'source' => 'src/Helpers', + 'docs' => 'docs/src/pages/system-reference/backend/helpers', + ], + [ + 'label' => 'Providers', + 'source' => 'src/Providers', + 'docs' => 'docs/src/pages/system-reference/backend/providers', + ], + [ + 'label' => 'Events', + 'source' => 'src/Events', + 'docs' => 'docs/src/pages/system-reference/backend/events', + ], + [ + 'label' => 'Notifications', + 'source' => 'src/Notifications', + 'docs' => 'docs/src/pages/system-reference/backend/notifications', + ], + [ + 'label' => 'Generators', + 'source' => 'src/Generators', + 'docs' => 'docs/src/pages/system-reference/backend/generators', + ], + [ + 'label' => 'Hydrates', + 'source' => 'src/Hydrates', + 'docs' => 'docs/src/pages/system-reference/backend/hydrates', + 'recursive' => true, + ], + [ + 'label' => 'Core Services', + 'source' => 'src/Services', + 'docs' => 'docs/src/pages/system-reference/backend/core-services', + 'recursive' => true, + ], + [ + 'label' => 'Package Traits', + 'source' => 'src/Traits', + 'docs' => 'docs/src/pages/system-reference/backend/package-traits', + 'recursive' => true, + ], + [ + 'label' => 'Contracts', + 'source' => 'src/Contracts', + 'docs' => 'docs/src/pages/system-reference/backend/contracts', + 'recursive' => true, + ], + [ + 'label' => 'Exceptions', + 'source' => 'src/Exceptions', + 'docs' => 'docs/src/pages/system-reference/backend/exceptions', + ], + [ + 'label' => 'Transformers', + 'source' => 'src/Transformers', + 'docs' => 'docs/src/pages/system-reference/backend/transformers', + ], + [ + 'label' => 'Activators', + 'source' => 'src/Activators', + 'docs' => 'docs/src/pages/system-reference/backend/activators', + ], + [ + 'label' => 'Brokers', + 'source' => 'src/Brokers', + 'docs' => 'docs/src/pages/system-reference/backend/brokers', + ], + [ + 'label' => 'Repository Traits', + 'source' => 'src/Repositories/Traits', + 'docs' => 'docs/src/pages/system-reference/backend/repository-traits', + 'recursive' => true, + ], + ]; + } + + public function handle(): int + { + $packageRoot = $this->resolvePackageRoot(); + $filterSection = $this->option('section'); + $failOnMissing = $this->option('fail-on-missing'); + + $this->components->info('Modularous Documentation Audit'); + $this->line(' Package root: ' . $packageRoot); + $this->newLine(); + + $totalSource = 0; + $totalDocumented = 0; + $allMissing = []; + $summaryRows = []; + + foreach (static::sections() as $section) { + if ($filterSection && ! Str::contains(Str::lower($section['label']), Str::lower($filterSection))) { + continue; + } + + $sourceDir = $packageRoot . '/' . $section['source']; + $docsDir = $packageRoot . '/' . $section['docs']; + + if (! is_dir($sourceDir)) { + continue; + } + + $sourceFiles = $this->scanSourceFiles($sourceDir, $section); + $docFiles = is_dir($docsDir) ? $this->scanDocFiles($docsDir) : []; + + $missing = []; + + foreach ($sourceFiles as $relPath => $className) { + $expectedSlug = Str::kebab($className); + + if (! isset($docFiles[$expectedSlug])) { + $missing[] = [ + 'class' => $className, + 'source' => $section['source'] . '/' . $relPath, + 'expected' => $expectedSlug . '.md', + ]; + } + } + + $srcCount = count($sourceFiles); + $docCount = $srcCount - count($missing); + $totalSource += $srcCount; + $totalDocumented += $docCount; + + $status = count($missing) === 0 ? '✓ Complete' : '✗ ' . count($missing) . ' missing'; + + $summaryRows[] = [ + $section['label'], + (string) $srcCount, + (string) $docCount, + $status, + ]; + + if (count($missing) > 0) { + $allMissing[$section['label']] = $missing; + } + } + + $this->table( + ['Section', 'Source Files', 'Documented', 'Status'], + $summaryRows, + ); + + $this->newLine(); + $coverage = $totalSource > 0 ? round(($totalDocumented / $totalSource) * 100) : 100; + $this->components->info(sprintf( + 'Coverage: %d/%d files (%d%%)', + $totalDocumented, + $totalSource, + $coverage, + )); + + if (! empty($allMissing)) { + $this->newLine(); + $this->components->warn('Missing documentation:'); + + foreach ($allMissing as $sectionLabel => $files) { + $this->newLine(); + $this->components->twoColumnDetail("{$sectionLabel}", count($files) . ' file(s)'); + + foreach ($files as $file) { + $this->components->bulletList([ + "{$file['class']} — {$file['source']}", + ]); + } + } + + if ($failOnMissing) { + $this->newLine(); + $this->components->error(sprintf( + '%d source file(s) have no documentation.', + $totalSource - $totalDocumented, + )); + + return self::FAILURE; + } + } else { + $this->newLine(); + $this->components->info('All tracked source files are documented.'); + } + + return self::SUCCESS; + } + + /** + * Scan a source directory for PHP files and return [relativePath => className]. + */ + private function scanSourceFiles(string $dir, array $section): array + { + $recursive = $section['recursive'] ?? false; + $excludeDirs = $section['exclude_dirs'] ?? []; + + $finder = (new Finder) + ->files() + ->name('*.php') + ->in($dir); + + if (! $recursive) { + $finder->depth('== 0'); + } + + foreach ($excludeDirs as $exclude) { + $finder->notPath($exclude); + } + + $files = []; + + foreach ($finder as $file) { + $className = $file->getBasename('.php'); + $relPath = $file->getRelativePathname(); + $files[$relPath] = $className; + } + + return $files; + } + + /** + * Scan a docs directory recursively for .md files and return [slug => true]. + * Excludes index.md files since those are section overviews. + */ + private function scanDocFiles(string $dir): array + { + $finder = (new Finder) + ->files() + ->name('*.md') + ->notName('index.md') + ->in($dir); + + $slugs = []; + + foreach ($finder as $file) { + $slug = $file->getBasename('.md'); + $slugs[$slug] = true; + } + + return $slugs; + } + + private function resolvePackageRoot(): string + { + $default = base_path('packages/modularous'); + + if (is_dir($default)) { + return $default; + } + + return realpath(__DIR__ . '/../../..') ?: $default; + } +} diff --git a/src/Console/Make/MakeCmsControllerCommand.php b/src/Console/Make/MakeCmsControllerCommand.php new file mode 100644 index 000000000..6939a1adf --- /dev/null +++ b/src/Console/Make/MakeCmsControllerCommand.php @@ -0,0 +1,80 @@ +error('CMS features are not enabled.'); + + return self::FAILURE; + } + + $module = Modularity::findOrFail($this->argument('module')); + + $routeKey = trim((string) $this->argument('route')); + if ($routeKey === '') { + $this->error('Route key cannot be empty.'); + + return self::FAILURE; + } + + $routeKey = Str::slug($routeKey, '_'); + $studlyRoute = Str::studly(str_replace('_', ' ', $routeKey)); + $className = $studlyRoute . 'CmsController'; + + $namespace = config('modules.namespace', 'Modules') . '\\' . $module->getStudlyName() . '\\Http\\Controllers\\Front'; + $path = $module->getPath() . '/Http/Controllers/Front/' . $className . '.php'; + + Stub::setBasePath(dirname(__DIR__) . '/stubs'); + + $contents = (new Stub('/cms-controller.stub', [ + 'NAMESPACE' => $namespace, + 'CLASS' => $className, + 'MODULE_STUDLY' => $module->getStudlyName(), + 'ROUTE_STUDLY' => $studlyRoute, + ]))->render(); + + $dir = dirname($path); + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + + try { + (new FileGenerator($path, $contents))->withFileOverwrite((bool) $this->option('force'))->generate(); + } catch (FileAlreadyExistException $e) { + $this->error("File already exists: {$path}"); + + return self::FAILURE; + } + + $this->info("Created [{$className}] at {$path}"); + + return self::SUCCESS; + } +} diff --git a/src/Console/Make/MakeInputHydrateCommand.php b/src/Console/Make/MakeInputHydrateCommand.php index 15153cbf3..a8d13dce8 100644 --- a/src/Console/Make/MakeInputHydrateCommand.php +++ b/src/Console/Make/MakeInputHydrateCommand.php @@ -83,7 +83,7 @@ public function handle(): int protected function getArguments() { return [ - ['name', InputArgument::REQUIRED, 'The name of theme to be created.'], + ['name', InputArgument::REQUIRED, 'The name of input to be created.'], ]; } diff --git a/src/Console/Sync/SyncRevisionPermissionsCommand.php b/src/Console/Sync/SyncRevisionPermissionsCommand.php new file mode 100644 index 000000000..2527ed4bb --- /dev/null +++ b/src/Console/Sync/SyncRevisionPermissionsCommand.php @@ -0,0 +1,112 @@ +warn('No models using HasRevisions were found.'); + + return 0; + } + + // $guard = config('auth.defaults.guard', 'web'); + $guard = Modularity::getAuthGuardName(); + + $suffixes = [ + PermissionEnum::REVISION_APPROVE->value, + PermissionEnum::REVISION_REJECT->value, + PermissionEnum::REVISION_RESTORE->value, + ]; + + $created = []; + + foreach ($models as $modelClass) { + $prefixes = $this->resolveRoutePrefixesForModel($modelClass); + + foreach ($prefixes as $prefix) { + foreach ($suffixes as $suffix) { + $name = "{$prefix}_{$suffix}"; + + if ($this->option('dry-run')) { + $this->line("[dry-run] {$name}"); + + continue; + } + + $permission = Permission::firstOrCreate( + ['name' => $name, 'guard_name' => $guard], + [] + ); + + if ($permission->wasRecentlyCreated) { + $created[] = $name; + } + } + } + } + + if ($this->option('dry-run')) { + return 0; + } + + foreach ($created as $name) { + $this->info("Created permission: {$name}"); + } + + $this->info('Revision permissions synced.'); + + return 0; + } + + /** + * Uses {@see HasRevisions::revisionPermissionPrefix()} when overridden on the model. + * + * @return list + */ + protected function resolveRoutePrefixesForModel(string $modelClass): array + { + $method = new ReflectionMethod($modelClass, 'revisionPermissionPrefix'); + + if ($method->getDeclaringClass()->getName() === HasRevisions::class) { + $this->warn("Skipping {$modelClass}: override protected function revisionPermissionPrefix(): ?string (kebab-case route name)."); + + return []; + } + + $method->setAccessible(true); + $instance = new $modelClass; + $prefix = $method->invoke($instance); + + if (! is_string($prefix) || $prefix === '') { + $this->warn("Skipping {$modelClass}: revisionPermissionPrefix() returned empty."); + + return []; + } + + return [$prefix]; + } +} diff --git a/src/Console/stubs/cms-controller.stub b/src/Console/stubs/cms-controller.stub new file mode 100644 index 000000000..65ad9e340 --- /dev/null +++ b/src/Console/stubs/cms-controller.stub @@ -0,0 +1,17 @@ +}> + */ + public function bulkSheetFields(): array; + + /** + * Suggested filename for Content-Disposition on export. + */ + public function bulkSheetExportDownloadFilename(): string; + + /** + * @param list}> $records + * @return list> + */ + public function bulkSheetPrepareAndValidateRows(array $records): array; + + /** + * @param list> $prepared only valid rows + * @return array{created: int, updated: int} + */ + public function bulkSheetCommitPreparedRows(array $prepared): array; + + /** + * Write UTF-8 CSV (header + rows) to an already-open write stream. + * + * @param resource $resource + */ + public function bulkSheetStreamExport($resource): void; +} diff --git a/src/Entities/Enums/Permission.php b/src/Entities/Enums/Permission.php index 250395506..6748c7f13 100755 --- a/src/Entities/Enums/Permission.php +++ b/src/Entities/Enums/Permission.php @@ -2,6 +2,8 @@ namespace Unusualify\Modularity\Entities\Enums; +use Illuminate\Support\Str; + enum Permission: string { // case DASHBOARD = 'dashboard'; @@ -17,6 +19,9 @@ enum Permission: string case BULKDELETE = 'bulkDelete'; case BULKFORCEDELETE = 'bulkForceDelete'; case BULKRESTORE = 'bulkRestore'; + case REVISION_APPROVE = 'revisionApprove'; + case REVISION_REJECT = 'revisionReject'; + case REVISION_RESTORE = 'revisionRestore'; case ACTIVITY = 'activity'; case SHOW = 'show'; @@ -31,4 +36,14 @@ public static function get($caseName) return null; } + + public static function generatePermissionName($permission, $routeName) + { + return Str::kebab($routeName) . '_' . static::get($permission); + } + + public static function generatePermissionMiddlewareDefinition($permission, $routeName) + { + return 'can:' . self::generatePermissionName($permission, $routeName); + } } diff --git a/src/Entities/Enums/RevisionStatus.php b/src/Entities/Enums/RevisionStatus.php new file mode 100644 index 000000000..7fa1ddcc3 --- /dev/null +++ b/src/Entities/Enums/RevisionStatus.php @@ -0,0 +1,15 @@ + 'datetime', ]; public function __construct(array $attributes = []) { parent::__construct($attributes); - // Remember to update this if you had fields to the fillable array here + // Remember to update this if you add fields to the fillable array here // this is to allow child classes to provide a custom foreign key in fillable - if (count($this->fillable) == 2) { + if (count($this->fillable) == 3) { $this->fillable[] = mb_strtolower(str_replace('Revision', '', get_called_class())) . '_id'; } } @@ -31,8 +42,46 @@ public function user() return $this->belongsTo(User::class); } + /** + * Parent revision this row was branched from (e.g. after a restore, points at the snapshot that was applied). + */ + public function source(): BelongsTo + { + return $this->belongsTo(static::class, 'source_id'); + } + public function getByUserAttribute() { return isset($this->user) ? $this->user->name : 'System'; } + + public function isDraft(): bool + { + $data = json_decode($this->payload, true); + + $cmsSaveType = $data['cmsSaveType'] ?? ''; + + return Str::startsWith($cmsSaveType, 'draft-revision'); + } + + public function isPending(): bool + { + $status = $this->status ?? RevisionStatus::Approved->value; + + return $status === RevisionStatus::Pending->value; + } + + public function isApproved(): bool + { + $status = $this->status ?? RevisionStatus::Approved->value; + + return $status === RevisionStatus::Approved->value; + } + + public function isRejected(): bool + { + $status = $this->status ?? RevisionStatus::Approved->value; + + return $status === RevisionStatus::Rejected->value; + } } diff --git a/src/Entities/Traits/Core/HasCapabilities.php b/src/Entities/Traits/Core/HasCapabilities.php new file mode 100644 index 000000000..16d865e0e --- /dev/null +++ b/src/Entities/Traits/Core/HasCapabilities.php @@ -0,0 +1,80 @@ +getModel(); + $usersTable = $model->getTable(); + $capabilitiesTable = modularityConfig('tables.capabilities', 'um_capabilities'); + $roleCapabilityTable = modularityConfig('tables.role_capability', 'um_role_capability'); + $modelHasRolesTable = config('permission.table_names.model_has_roles', 'sp_model_has_roles'); + $modelMorphKey = config('permission.column_names.model_morph_key', 'model_id'); + + if (! class_exists(\Modules\SystemUser\Entities\Capability::class) + || ! Schema::hasTable($capabilitiesTable) + || ! Schema::hasTable($roleCapabilityTable) + || ! Schema::hasTable($modelHasRolesTable)) { + return; + } + + $capabilitiesSubQuery = DB::table("{$capabilitiesTable} as capabilities") + ->selectRaw( + "COALESCE(CONCAT('[', GROUP_CONCAT(DISTINCT JSON_QUOTE(capabilities.name) ORDER BY capabilities.name SEPARATOR ','), ']'), '[]')" + ) + ->join("{$roleCapabilityTable} as role_capability", 'role_capability.capability_id', '=', 'capabilities.id') + ->join("{$modelHasRolesTable} as model_has_roles", 'model_has_roles.role_id', '=', 'role_capability.role_id') + ->whereColumn("model_has_roles.{$modelMorphKey}", "{$usersTable}.id") + ->where('model_has_roles.model_type', $model::class) + ->where('capabilities.published', true); + + if ($builder->getQuery()->columns === null) { + $builder->select("{$usersTable}.*"); + } + + $builder->addSelect(['capabilities_payload' => $capabilitiesSubQuery]); + }); + } + + public function initializeHasCapabilities(): void + { + if (! modularityConfig('security.step_up.enabled', false)) { + return; + } + + $this->append(['capabilities']); + } + + protected function capabilities(): Attribute + { + return Attribute::make( + get: function ($value, array $attributes) { + $payload = $attributes['capabilities_payload'] ?? '[]'; + $decoded = json_decode((string) $payload, true); + + if (! is_array($decoded)) { + return []; + } + + return array_values(array_unique(array_filter($decoded, fn ($capability) => is_string($capability) && $capability !== ''))); + } + ); + } + + public function hasCapability(string $capability): bool + { + return in_array($capability, $this->capabilities ?? [], true); + } +} diff --git a/src/Entities/Traits/Core/Rolable.php b/src/Entities/Traits/Core/Rolable.php new file mode 100644 index 000000000..c994353b9 --- /dev/null +++ b/src/Entities/Traits/Core/Rolable.php @@ -0,0 +1,115 @@ +with('rolesMetaRelation'); + }); + } + + public function initializeRolable() + { + $this->append(['roles_meta', 'is_superadmin', 'is_client']); + } + + /** + * Minimal roles relation (id, name, title) for roles_meta. + * Does not affect the original roles relationship. + */ + public function rolesMetaRelation(): BelongsToMany + { + $rolesTable = config('permission.table_names.roles'); + $relation = $this->morphToMany( + config('permission.models.role'), + 'model', + config('permission.table_names.model_has_roles'), + config('permission.column_names.model_morph_key'), + PermissionRegistrar::$pivotRole + )->select("{$rolesTable}.id", "{$rolesTable}.name", "{$rolesTable}.title"); + + if (! PermissionRegistrar::$teams) { + return $relation; + } + + return $relation->wherePivot(PermissionRegistrar::$teamsKey, getPermissionsTeamId()) + ->where(function ($q) use ($rolesTable) { + $teamField = "{$rolesTable}." . PermissionRegistrar::$teamsKey; + $q->whereNull($teamField)->orWhere($teamField, getPermissionsTeamId()); + }); + } + + protected function rolesMeta(): Attribute + { + return new Attribute( + get: fn () => $this->rolesMetaRelation + ); + } + + public function isSuperadmin(): Attribute + { + return new Attribute( + get: fn () => collect($this->roles_meta) + ->contains(fn ($role) => $role['name'] === 'superadmin'), + ); + } + + public function isAdmin(): Attribute + { + return new Attribute( + get: fn () => collect($this->roles_meta) + ->contains(fn ($role) => $role['name'] === 'admin'), + ); + } + + /** + * @deprecated Use $this->is_client instead + */ + public function isClient(): bool + { + return $this->is_client; + } + + public function getIsClientAttribute() + { + return collect($this->roles_meta) + ->contains(fn ($role) => Str::startsWith($role['name'], 'client')); + } + + public function existRole(string|Model $role): bool + { + $roleName = $role instanceof Model ? $role->name : $role; + + return collect($this->roles_meta) + ->contains(fn ($role) => $role['name'] === $roleName); + } + + public function existRoles(array $roles): bool + { + return collect($roles) + ->every(fn ($role) => $this->existRole($role)); + } + + public function existAnyRole(array $roles): bool + { + return collect($roles) + ->some(fn ($role) => $this->existRole($role)); + } + + public function hasPermission($permission): bool + { + return $this->hasPermissionTo($permission); + } +} diff --git a/src/Entities/Traits/HasRevisions.php b/src/Entities/Traits/HasRevisions.php new file mode 100755 index 000000000..01fc6b9a7 --- /dev/null +++ b/src/Entities/Traits/HasRevisions.php @@ -0,0 +1,248 @@ +isRevisionWorkflowEnabled ?? false; + } + + /** + * Kebab-case route segment for permissions, e.g. "page" → "page_revision_approve". + * Override in the composed model; do not redeclare as a property. + */ + protected function revisionPermissionPrefix(): ?string + { + if(method_exists($this, 'getModule') && ($module = $this->getModule()) instanceof Module) { + $routeName = $this->getRouteName(); + + return snakeCase($routeName); + } + + return null; + } + + /** + * Defines the one-to-many relationship for revisions. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function revisions() + { + return $this->hasMany($this->getRevisionModel()) + ->orderBy('created_at', 'desc') + ->with(['user', 'source']); + } + + /** + * Latest revision row by id (the only row that may be {@see RevisionStatus::Pending} when workflow is on). + */ + public function latestRevision(): HasOne + { + return $this->hasOne($this->getRevisionModel())->latestOfMany('id'); + } + + public function usesRevisionWorkflow(): bool + { + return $this->revisionWorkflowEnabled() === true + && is_string($this->revisionPermissionPrefix()) + && $this->revisionPermissionPrefix() !== ''; + } + + /** + * Id of the current pending revision when the newest revision row has status pending; otherwise null. + */ + public function getPendingRevisionId(): ?int + { + $revisionModel = $this->getRevisionModel(); + $instance = new $revisionModel; + + if (! Schema::hasColumn($instance->getTable(), 'status')) { + return null; + } + + $latest = $this->revisions()->orderByDesc('id')->first(); + + if (! $latest || ($latest->status ?? RevisionStatus::Approved->value) !== RevisionStatus::Pending->value) { + return null; + } + + return (int) $latest->id; + } + + /** + * True when the newest revision row is pending. That state locks update and restore. + */ + public function isRevisionWorkflowLocked(): bool + { + if (! $this->usesRevisionWorkflow()) { + return false; + } + + return $this->latestRevisionIsPending(); + } + + /** + * @deprecated Use {@see isRevisionWorkflowLocked()} for workflow models. + */ + public function hasPendingRevision(): bool + { + return $this->isRevisionWorkflowLocked(); + } + + protected function latestRevisionIsPending(): bool + { + $revisionModel = $this->getRevisionModel(); + $instance = new $revisionModel; + + if (! Schema::hasColumn($instance->getTable(), 'status')) { + return false; + } + + $latest = $this->revisions()->orderByDesc('id')->first(); + + if (! $latest) { + return false; + } + + return ($latest->status ?? RevisionStatus::Approved->value) === RevisionStatus::Pending->value; + } + + public function userCanApproveRevisions(): bool + { + if (! $this->usesRevisionWorkflow()) { + return true; + } + + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + return $user && Gate::forUser($user)->allows(Permission::generatePermissionName('REVISION_APPROVE', $this->revisionPermissionPrefix())); + } + + public function userCanRejectRevisions(): bool + { + if (! $this->usesRevisionWorkflow()) { + return true; + } + + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + return $user && Gate::forUser($user)->allows(Permission::generatePermissionName('REVISION_REJECT', $this->revisionPermissionPrefix())); + } + + public function userCanRestoreRevisions(): bool + { + if (! $this->usesRevisionWorkflow()) { + return true; + } + + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + return $user && Gate::forUser($user)->allows(Permission::generatePermissionName('REVISION_RESTORE', $this->revisionPermissionPrefix())); + } + + /** + * Scope a query to only include the current user's revisions. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeMine($query) + { + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + if (! $user) { + return $query->whereRaw('1 = 0'); + } + + return $query->whereHas('revisions', function ($query) { + $query->where('user_id', Auth::guard(Modularity::getAuthGuardName())->id()); + }); + } + + /** + * Returns an array of revisions for the CMS views. + * + * @return array + */ + public function revisionsArray() + { + $revisions = $this->revisions; // ordered DESC (newest first) + $total = $revisions->count(); + + $versionMap = $revisions->mapWithKeys(function ($revision, $index) use ($total) { + return [$revision->id => $total - $index]; + }); + + return $revisions + ->map(function ($revision, $index) use ($total, $versionMap) { + $sourceLabel = $revision->source_id && isset($versionMap[$revision->source_id]) + ? 'V' . $versionMap[$revision->source_id] + : null; + + return [ + 'id' => $revision->id, + 'author' => $revision->user->name ?? 'Unknown', + 'datetime' => $revision->created_at->toIso8601String(), + 'label' => 'V' . ($total - $index), + 'source_label' => $sourceLabel, + 'is_restored' => (bool) $revision->source_id, + 'source_datetime' => $revision->source?->created_at?->toIso8601String(), + 'status' => $revision->status ?? 'approved', + ]; + }) + ->toArray(); + } + + /** + * Deletes revisions from specific collection position + * Used to keep max revision on specific Twill's module. + */ + public function deleteSpecificRevisions(int $maxRevisions): void + { + if (isset($this->limitRevisions) && $this->limitRevisions > 0) { + $maxRevisions = $this->limitRevisions; + } + + $this->revisions()->get()->slice($maxRevisions)->each->delete(); + } + + public function getRevisionModel() + { + if (property_exists($this, 'revisionModel') && is_string($this->revisionModel) && @class_exists($this->revisionModel)) { + return $this->revisionModel; + } + + $modelClass = get_class($this); + $candidates = [ + preg_replace('/\\\\Entities\\\\([^\\\\]+)$/', '\\Entities\\Revisions\\$1Revision', $modelClass), + modularityConfig('namespace') . "\\Models\\Revisions\\" . class_basename($this) . 'Revision', + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && @class_exists($candidate)) { + return $candidate; + } + } + + throw new RuntimeException("Revision model could not be resolved for [{$modelClass}]. Define a \$revisionModel property."); + } +} diff --git a/src/Entities/Traits/HasSlug.php b/src/Entities/Traits/HasSlug.php index 0d32d40ec..2de80fd4a 100755 --- a/src/Entities/Traits/HasSlug.php +++ b/src/Entities/Traits/HasSlug.php @@ -12,9 +12,21 @@ trait HasSlug { private $nb_variation_slug = 3; + /** + * When true, {@see saved} skips {@see setSlugs()} (e.g. explicit `slugs` payload via repository). + * Not persisted. Set on each repository {@see beforeSaveSlugsTrait} run; intentionally left sticky through + * subsequent {@see saved} fires in the same request (e.g. translation saves), so slug rows are not rebuilt + * from {@see slugAttributes} after the editor-controlled payload was processed. + */ + public bool $modularitySkipAutomaticSlugSync = false; + protected static function bootHasSlug() { static::saved(function ($model) { + if ($model->modularitySkipAutomaticSlugSync) { + return; + } + $model->setSlugs(); }); @@ -127,24 +139,34 @@ public function setSlugs($restoring = false) */ public function updateOrNewSlug($slugParams, $restoring = false) { + $slugParams = $this->normalizeSlugParamsPayload($slugParams); + + $targetActive = array_key_exists('active', $slugParams) + ? $this->normalizeSlugActiveRequestValue($slugParams['active']) + : true; + $slugParams['active'] = $targetActive; + if (in_array($slugParams['locale'], modularityConfig('slug_utf8_languages', []))) { $slugParams['slug'] = $this->getUtf8Slug($slugParams['slug']); } else { $slugParams['slug'] = Str::slug($slugParams['slug']); } - // active old slug if already existing or create a new one if ( (($oldSlug = $this->getExistingSlug($slugParams)) != null) && ($restoring ? $slugParams['slug'] === $this->suffixSlugIfExisting($slugParams) : true) ) { - if (! $oldSlug->active && ($slugParams['active'] ?? false)) { - $this->getSlugModelClass()::where('id', $oldSlug->id)->update(['active' => 1]); - $this->disableLocaleSlugs($oldSlug->locale, $oldSlug->id); + // Always persist requested `active` for an existing slug row (previously only re-activation ran). + $this->getSlugModelClass()::where('id', $oldSlug->id)->update(['active' => $targetActive ? 1 : 0]); + + if ($targetActive) { + $this->disableLocaleSlugs($slugParams['locale'], $oldSlug->id); } - } else { - $this->addOneSlug($slugParams); + + return; } + + $this->addOneSlug($slugParams); } /** @@ -281,13 +303,142 @@ public function getSlugAttribute() return $this->getSlug(); } + /** + * Coerce slug `active` from request / JSON / model (bool, 0/1, `"false"` string, etc.). + * Note: `(bool) 'false'` is true in PHP; this avoids that trap. + */ + public function normalizeSlugActiveRequestValue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return ((int) $value) !== 0; + } + + if ($value === null || $value === '') { + return false; + } + + if (is_string($value)) { + $v = strtolower(trim($value)); + if (in_array($v, ['0', 'false', 'off', 'no', 'inactive', 'disabled'], true)) { + return false; + } + if (in_array($v, ['1', 'true', 'on', 'yes', 'active', 'enabled'], true)) { + return true; + } + } + + $filtered = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + return $filtered ?? (bool) $value; + } + + /** + * Translation / model attribute used as slug source may be a legacy string or + * `['slug' => string, 'active' => bool]` from the admin form. + * + * @param mixed $value + * @return array{slug: string, active: bool} + */ + protected function normalizeSlugSourceValue($value): array + { + if (is_array($value) && array_key_exists('slug', $value)) { + $slug = $value['slug']; + + return [ + 'slug' => $slug === null || $slug === '' ? '' : (string) $slug, + 'active' => ! array_key_exists('active', $value) ? true : $this->normalizeSlugActiveRequestValue($value['active']), + ]; + } + + if (is_string($value)) { + $trim = trim($value); + if ($trim !== '' && ($trim[0] === '{' || $trim[0] === '[')) { + $decoded = json_decode($value, true); + if (is_array($decoded) && array_key_exists('slug', $decoded)) { + return $this->normalizeSlugSourceValue($decoded); + } + } + } + + if ($value === null || $value === '') { + return ['slug' => '', 'active' => true]; + } + + return [ + 'slug' => is_scalar($value) ? (string) $value : '', + 'active' => true, + ]; + } + + /** + * Defensive unwrap when {@see updateOrNewSlug} receives a nested slug payload. + * + * @param array $slugParams + * @return array + */ + protected function normalizeSlugParamsPayload(array $slugParams): array + { + if (isset($slugParams['slug']) && is_array($slugParams['slug']) && array_key_exists('slug', $slugParams['slug'])) { + $nested = $slugParams['slug']; + $slugParams['slug'] = $nested['slug'] === null || $nested['slug'] === '' ? '' : (string) $nested['slug']; + if (array_key_exists('active', $nested)) { + $slugParams['active'] = $this->normalizeSlugActiveRequestValue($nested['active']); + } + } + + return $slugParams; + } + + /** + * Whether `$attribute` is stored on translation rows (Astrotomic) rather than on the owner model. + */ + public function slugAttributeIsTranslated(string $attribute): bool + { + if ($attribute === '') { + return false; + } + + if (! method_exists($this, 'getTranslatedAttributes')) { + return false; + } + + return in_array($attribute, $this->getTranslatedAttributes(), true); + } + + /** + * Whether the primary slug source column ({@see $slugAttributes} first entry) lives on translation rows. + */ + public function slugPrimaryAttributeIsTranslated(): bool + { + $slugAttributes = $this->getSlugAttributes(); + $primary = $slugAttributes[0] ?? null; + + if ($primary === null) { + return false; + } + + return $this->slugAttributeIsTranslated($primary); + } + /** * @param string|null $locale * @return array|null */ public function getSlugParams($locale = null) { - if (count(getLocales()) === 1 || ! (isset($this->translations) && count($this->translations) > 0)) { + // Use translation rows only when the slug source attribute is translated; otherwise a model can be + // HasTranslation while slug lives on the parent (e.g. Page.slug_segment) and iterating translations would + // repeat the same parent value once per locale and blur owner vs translation responsibility. + if ( + count(getLocales()) === 1 + || ! isset($this->translations) + || count($this->translations) < 1 + || ! $this->slugPrimaryAttributeIsTranslated() + ) { $slugParams = $this->getSingleSlugParams($locale); if ($slugParams != null && ! empty($slugParams)) { return $slugParams; @@ -315,9 +466,12 @@ public function getSlugParams($locale = null) throw new \Exception("You must define the field {$slugAttribute} in your model"); } + $rawSlug = $translation->$slugAttribute ?? $this->$slugAttribute; + $normalized = $this->normalizeSlugSourceValue($rawSlug); + $slugParam = [ - 'active' => $translation->active, - 'slug' => $translation->$slugAttribute ?? $this->$slugAttribute, + 'active' => $normalized['active'], + 'slug' => $normalized['slug'], 'locale' => $translation->locale, ] + $slugDependenciesAttributes; @@ -332,6 +486,12 @@ public function getSlugParams($locale = null) return $locale == null ? $slugParams : null; } + /** + * Model properties used to derive URL slugs (first entry is the main source; the rest are dependency columns + * merged into slug rows). Not the canonical `slugs` request/editor payload from the slug input. + * + * @return list + */ public function getSlugAttributes() { return $this->slugAttributes ?? []; @@ -362,9 +522,11 @@ public function getSingleSlugParams($locale = null) throw new \Exception("You must define the field {$slugAttribute} in your model"); } + $normalized = $this->normalizeSlugSourceValue($this->$slugAttribute); + $slugParam = [ - 'active' => 1, - 'slug' => $this->$slugAttribute, + 'active' => $normalized['active'] ? 1 : 0, + 'slug' => $normalized['slug'], 'locale' => $appLocale, ] + $slugDependenciesAttributes; diff --git a/src/Entities/Traits/HasTranslatableMetadata.php b/src/Entities/Traits/HasTranslatableMetadata.php new file mode 100644 index 000000000..74945702a --- /dev/null +++ b/src/Entities/Traits/HasTranslatableMetadata.php @@ -0,0 +1,33 @@ + + */ + public static function translatableMetadataAttributeNames(): array + { + return TranslatableMetadata::TRANSLATED_ATTRIBUTES; + } + + public function initializeHasTranslatableMetadata() + { + if( classHasTrait($this, HasTranslation::class)) { + $this->translatedAttributes = array_unique(array_merge($this->translatedAttributes, $this->translatableMetadataAttributeNames())); + } else if (classHasTrait($this, IsSingular::class)) { + $this->mergeFillable($this->translatableMetadataAttributeNames()); + } + } +} diff --git a/src/Entities/Traits/IsSingular.php b/src/Entities/Traits/IsSingular.php index 54b830eb6..cb0fd9174 100644 --- a/src/Entities/Traits/IsSingular.php +++ b/src/Entities/Traits/IsSingular.php @@ -2,6 +2,7 @@ namespace Unusualify\Modularity\Entities\Traits; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Unusualify\Modularity\Entities\Scopes\SingularScope; @@ -77,6 +78,23 @@ public function isPublished() return (bool) ($this->published ?? $this->content['published'] ?? true); } + public function scopeVisible($query) + { + $now = Carbon::now(); + + $query->where(function ($query) use ($now) { + $query->whereNull("{$this->getTable()}.content->publish_start_date") + ->orWhere("{$this->getTable()}.content->publish_start_date", '<=', $now); + }); + + $query->where(function ($query) use ($now) { + $query->whereNull("{$this->getTable()}.content->publish_end_date") + ->orWhere("{$this->getTable()}.content->publish_end_date", '>=', $now); + }); + + return $query; + } + final public function getTable() { return Modularity::config('tables.singletons', 'modularity_singletons'); diff --git a/src/Entities/Traits/Publishable.php b/src/Entities/Traits/Publishable.php new file mode 100644 index 000000000..96222d89e --- /dev/null +++ b/src/Entities/Traits/Publishable.php @@ -0,0 +1,24 @@ +mergeFillable([ + 'published', + ...($this->hasPublishDates() ? ['publish_start_date', 'publish_end_date'] : []), + ]); + + $this->mergeCasts([ + 'published' => 'boolean', + ...($this->hasPublishDates() ? ['publish_start_date' => 'datetime', 'publish_end_date' => 'datetime'] : []), + ]); + } + + protected function hasPublishDates(): bool + { + return $this->usePublishDates ?? false; + } +} diff --git a/src/Entities/Traits/HasPresenter.php b/src/Entities/Traits/Secondary/HasPresenter.php similarity index 95% rename from src/Entities/Traits/HasPresenter.php rename to src/Entities/Traits/Secondary/HasPresenter.php index 4bea41a72..a51db15e7 100755 --- a/src/Entities/Traits/HasPresenter.php +++ b/src/Entities/Traits/Secondary/HasPresenter.php @@ -1,6 +1,6 @@ hasMany($this->getRevisionModel())->orderBy('created_at', 'desc'); - } - - /** - * Scope a query to only include the current user's revisions. - * - * @param Builder $query - * @return Builder - */ - public function scopeMine($query) - { - return $query->whereHas('revisions', function ($query) { - $query->where('user_id', auth('twill_users')->user()->id); - }); - } - - /** - * Returns an array of revisions for the CMS views. - * - * @return array - */ - public function revisionsArray() - { - return $this->revisions->map(function ($revision, $index) { - return [ - 'id' => $revision->id, - 'author' => $revision->user->name ?? 'Unknown', - 'datetime' => $revision->created_at->toIso8601String(), - 'label' => $index === 0 ? twillTrans('twill::lang.publisher.current') : '', - ]; - })->toArray(); - } - - protected function getRevisionModel() - { - $revision = modularityConfig('namespace') . "\Models\Revisions\\" . class_basename($this) . 'Revision'; - - if (@class_exists($revision)) { - return $revision; - } - - return TwillCapsules::getCapsuleForModel(class_basename($this))->getRevisionModel(); - } -} diff --git a/src/Entities/User.php b/src/Entities/User.php index c85d81399..634c26fe0 100755 --- a/src/Entities/User.php +++ b/src/Entities/User.php @@ -7,20 +7,18 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Session; -use Illuminate\Support\Str; use Laravel\Sanctum\HasApiTokens; -use Spatie\Permission\PermissionRegistrar; -use Spatie\Permission\Traits\HasRoles; use Unusualify\Modularity\Database\Factories\UserFactory; use Unusualify\Modularity\Entities\Traits\Auth\CanRegister; use Unusualify\Modularity\Entities\Traits\Auth\HasOauth; use Unusualify\Modularity\Entities\Traits\Core\HasCompany; +use Unusualify\Modularity\Entities\Traits\Core\HasCapabilities; use Unusualify\Modularity\Entities\Traits\Core\ModelHelpers; +use Unusualify\Modularity\Entities\Traits\Core\Rolable; use Unusualify\Modularity\Entities\Traits\HasFileponds; use Unusualify\Modularity\Entities\Traits\IsTranslatable; use Unusualify\Modularity\Notifications\GeneratePasswordNotification; @@ -30,7 +28,8 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma { use HasApiTokens, HasFactory, - HasRoles, + Rolable, + HasCapabilities, IsTranslatable, ModelHelpers, Notifiable, @@ -80,12 +79,6 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'ui_preferences' => 'array', ]; - protected $appends = [ - 'roles_meta', - 'is_client', - 'is_superadmin', - ]; - protected static function boot() { parent::boot(); @@ -102,10 +95,6 @@ protected static function boot() $model->saveQuietly(); } }); - - static::addGlobalScope('roles_meta', function ($query) { - $query->with('rolesMetaRelation'); - }); } protected static function newFactory(): Factory @@ -113,39 +102,6 @@ protected static function newFactory(): Factory return UserFactory::new(); } - /** - * Minimal roles relation (id, name, title) for roles_meta. - * Does not affect the original roles relationship. - */ - public function rolesMetaRelation(): BelongsToMany - { - $rolesTable = config('permission.table_names.roles'); - $relation = $this->morphToMany( - config('permission.models.role'), - 'model', - config('permission.table_names.model_has_roles'), - config('permission.column_names.model_morph_key'), - PermissionRegistrar::$pivotRole - )->select("{$rolesTable}.id", "{$rolesTable}.name", "{$rolesTable}.title"); - - if (! PermissionRegistrar::$teams) { - return $relation; - } - - return $relation->wherePivot(PermissionRegistrar::$teamsKey, getPermissionsTeamId()) - ->where(function ($q) use ($rolesTable) { - $teamField = "{$rolesTable}." . PermissionRegistrar::$teamsKey; - $q->whereNull($teamField)->orWhere($teamField, getPermissionsTeamId()); - }); - } - - protected function rolesMeta(): Attribute - { - return new Attribute( - get: fn () => $this->rolesMetaRelation - ); - } - public function setImpersonating($id) { Session::put('impersonate', $id); @@ -161,41 +117,11 @@ public function isImpersonating() return Session::has('impersonate'); } - public function isSuperadmin(): Attribute - { - return new Attribute( - get: fn () => collect($this->roles_meta) - ->contains(fn ($role) => $role['name'] === 'superadmin'), - ); - } - - public function isAdmin(): Attribute - { - return new Attribute( - get: fn () => collect($this->roles_meta) - ->contains(fn ($role) => $role['name'] === 'admin'), - ); - } - - /** - * @deprecated Use $this->is_client instead - */ - public function isClient(): bool - { - return $this->is_client; - } - - public function getIsClientAttribute() - { - return collect($this->roles_meta) - ->contains(fn ($role) => Str::startsWith($role['name'], 'client')); - } - protected function avatar(): Attribute { return new Attribute( - get: fn ($value) => $this->fileponds() - ->where('role', 'avatar') + get: fn ($value) => $this->fileponds + ->filter(fn ($filepond) => $filepond->role === 'avatar') ->first()?->mediableFormat()['source'] ?? '/vendor/modularity/jpg/anonymous.jpg', ); } diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php new file mode 100644 index 000000000..ab0055629 --- /dev/null +++ b/src/Exceptions/ValidationException.php @@ -0,0 +1,35 @@ +errors(); + $message = $this->summarizeErrors($errors); + + $this->response = response()->json([ + 'message' => $message, + 'errors' => $errors, + 'variant' => $variant, + ], 422); + + return $this; + } + + protected function summarizeErrors(array $errors): string + { + foreach ($errors as $messages) { + if (is_array($messages) && isset($messages[0]) && is_string($messages[0]) && $messages[0] !== '') { + return $messages[0]; + } + } + + return __('The given data was invalid.'); + } +} diff --git a/src/Facades/Modularity.php b/src/Facades/Modularity.php index ad659b814..baf007977 100755 --- a/src/Facades/Modularity.php +++ b/src/Facades/Modularity.php @@ -13,6 +13,8 @@ * @method static string getModulePath(string $moduleName) * @method static string assetPath(string $module) * @method static string moduleAsset(string $module, string $asset) + * @method static array getModuleRouteModelSelectItems(bool $onlyParentSegmentModels = false) + * @method static string|null resolveTargetModuleRouteForModelClass(string $modelClass) * @method static void enableModule(string $moduleName) * @method static void disableModule(string $moduleName) * @method static void deleteModule(string $moduleName) diff --git a/src/Facades/ValidationException.php b/src/Facades/ValidationException.php new file mode 100644 index 000000000..efa26bbd2 --- /dev/null +++ b/src/Facades/ValidationException.php @@ -0,0 +1,21 @@ + [ 'default', $inputPropToFormat, // schema ], ]; - if ($inputToFormat && $inputPropToFormat) { - if ($inputToFormat == '3.content.schema.wrap-content.schema.1_content') { - // dump($inputToFormat, $inputPropToFormat, $setProp); - } - $events[] = "formatSet:{$inputToFormat}:{$inputPropToFormat}:{$setProp}"; + if ($targetInputName && $targetPropName) { + $events[] = implode(':', [$eventName, $targetInputName, $targetPropName, $setProp, ...($modelNotation ? [modelNotation] : [])]); } break; diff --git a/src/Helpers/migrations_helpers.php b/src/Helpers/migrations_helpers.php index 94015ddaa..f7f7cf26b 100755 --- a/src/Helpers/migrations_helpers.php +++ b/src/Helpers/migrations_helpers.php @@ -2,6 +2,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Str; +use Unusualify\Modularity\Support\PublishableMetadata; +use Unusualify\Modularity\Support\TranslatableMetadata; if (! function_exists('modularityIncrementsMethod')) { /** @@ -54,15 +56,7 @@ function createDefaultTableFields($table, $has_name = true) */ function createDefaultExtraTableFields($table, $softDeletes = true, $published = true, $publishDates = false, $visibility = false) { - - if ($published) { - $table->boolean('published')->default(false); - } - - if ($publishDates) { - $table->timestamp('publish_start_date')->nullable(); - $table->timestamp('publish_end_date')->nullable(); - } + PublishableMetadata::addColumns($table, $published, $publishDates); if ($visibility) { $table->boolean('public')->default(true); @@ -137,7 +131,7 @@ function createDefaultSlugsTableFields($table, $tableNameSingular, $tableNamePlu $table->timestamps(); $table->string('slug'); $table->string('locale', 7)->index(); - $table->boolean('active'); + $table->boolean('active')->default(true); $table->foreign("{$tableNameSingular}_id", "fk_{$tableNameSingular}_slugs_{$tableNameSingular}_id")->references('id')->on($tableNamePlural)->onDelete('CASCADE')->onUpdate('NO ACTION'); } } @@ -234,6 +228,9 @@ function createDefaultMorphPivotTableFields($table, $modelName = null, $tableNam if (! function_exists('createDefaultRevisionsTableFields')) { /** + * Standard revision table: payload, optional lineage (source_id), and workflow columns (status, approved_at, approved_by). + * Pending state is represented by the latest row’s status only — no column on the subject model. + * * @param Blueprint $table * @param string $tableNameSingular * @param string|null $tableNamePlural @@ -248,10 +245,30 @@ function createDefaultRevisionsTableFields($table, $tableNameSingular, $tableNam $table->{modularityIncrementsMethod()}('id'); $table->{modularityIntegerMethod()}("{$tableNameSingular}_id")->unsigned(); $table->{modularityIntegerMethod()}('user_id')->unsigned()->nullable(); + $table->unsignedBigInteger('source_id')->nullable(); + + $table->string('status', 32)->default('approved'); + $table->timestamp('approved_at')->nullable(); + $table->{modularityIntegerMethod()}('approved_by')->unsigned()->nullable(); $table->timestamps(); $table->json('payload'); $table->foreign("{$tableNameSingular}_id")->references('id')->on("{$tableNamePlural}")->onDelete('cascade'); $table->foreign('user_id')->references('id')->on(modularityConfig('tables.users', 'um_users'))->onDelete('set null'); + $table->foreign('approved_by')->references('id')->on(modularityConfig('tables.users', 'um_users'))->onDelete('set null'); + } +} + +if (! function_exists('createTranslatableMetadataFields')) { + /** + * Translatable metadata (SEO, canonical, robots, sitemap flag) columns for {@code *_translations} tables. + * + * Mirrors {@see TranslatableMetadata::TRANSLATED_ATTRIBUTES}; use with {@see \Unusualify\Modularity\Entities\Traits\HasTranslatableMetadata}. + * + * @param bool $withSitemapInclude When false, omits sitemap_include (not recommended for new modules). + */ + function createTranslatableMetadataFields(Blueprint $table, bool $withSitemapInclude = true): void + { + TranslatableMetadata::addColumns($table, $withSitemapInclude); } } diff --git a/src/Helpers/module.php b/src/Helpers/module.php index 9f6f2d08e..69ff1a30f 100755 --- a/src/Helpers/module.php +++ b/src/Helpers/module.php @@ -195,7 +195,7 @@ function moduleRoute($moduleName, $prefix, $action = '', $parameters = [], $abso $routeName .= "{$snakeName}"; } // dd($snakeName, $parameters); - if (preg_match('/edit|show|update|destroy|duplicate|restoreRevision|preview/', $action) && ! array_key_exists($snakeName, $parameters) && ! $singleton) { + if (preg_match('/edit|show|update|destroy|duplicate|restoreRevision|approveRevision|rejectRevision|preview/', $action) && ! array_key_exists($snakeName, $parameters) && ! $singleton) { $parameters[$snakeName] = ':id'; // dd( // $routeName, diff --git a/src/Http/Controllers/ApiController.php b/src/Http/Controllers/ApiController.php index 58860f78a..bbbeb13af 100644 --- a/src/Http/Controllers/ApiController.php +++ b/src/Http/Controllers/ApiController.php @@ -7,15 +7,15 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Response; -use Unusualify\Modularity\Http\Controllers\Traits\ApiAuthentication; -use Unusualify\Modularity\Http\Controllers\Traits\ApiFiltering; -use Unusualify\Modularity\Http\Controllers\Traits\ApiPagination; -use Unusualify\Modularity\Http\Controllers\Traits\ApiRateLimiting; -use Unusualify\Modularity\Http\Controllers\Traits\ApiRelationships; -use Unusualify\Modularity\Http\Controllers\Traits\ApiResponses; -use Unusualify\Modularity\Http\Controllers\Traits\ApiSorting; -use Unusualify\Modularity\Http\Controllers\Traits\ApiValidation; -use Unusualify\Modularity\Http\Controllers\Traits\ApiVersioning; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiAuthentication; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiFiltering; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiPagination; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiRateLimiting; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiRelationships; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiResponses; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiSorting; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiValidation; +use Unusualify\Modularity\Http\Controllers\Traits\API\ApiVersioning; abstract class ApiController extends CoreController { diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index 69425aec5..b518a0fd9 100755 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -14,18 +14,18 @@ use Illuminate\Validation\ValidationException; use Illuminate\View\Factory as ViewFactory; use Illuminate\View\View; -use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException; -use PragmaRX\Google2FA\Exceptions\InvalidCharactersException; -use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException; -use PragmaRX\Google2FA\Google2FA; -use Unusualify\Modularity\Entities\User; use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Http\Controllers\Traits\Utilities\EnforcesMfaSetupOnLogin; +use Unusualify\Modularity\Http\Controllers\Traits\Utilities\HandlesMfaAuthentication; use Unusualify\Modularity\Http\Controllers\Traits\Utilities\HandlesOAuth; use Unusualify\Modularity\Services\MessageStage; class LoginController extends Controller { - use AuthenticatesUsers, HandlesOAuth; + use AuthenticatesUsers { + login as protected defaultPasswordLogin; + } + use EnforcesMfaSetupOnLogin, HandlesMfaAuthentication, HandlesOAuth; /** * @var AuthManager @@ -67,7 +67,20 @@ protected function guard() public function showForm() { - return $this->viewFactory->make(modularityBaseKey() . '::auth.login', $this->buildAuthViewData('login')); + $pageKey = $this->shouldUseMfaLoginFlow() + ? $this->mfaLoginPageKey() + : 'login'; + + return $this->viewFactory->make(modularityBaseKey() . '::auth.login', $this->buildAuthViewData($pageKey)); + } + + public function login(Request $request) + { + if ($this->shouldUseMfaLoginFlow()) { + return $this->handleMfaLoginRequest($request); + } + + return $this->defaultPasswordLogin($request); } /** @@ -75,7 +88,14 @@ public function showForm() */ public function showLogin2FaForm() { - return $this->viewFactory->make(modularityBaseKey() . '::auth.2fa'); + if (! $this->isMfaEnabled()) { + return $this->viewFactory->make(modularityBaseKey() . '::auth.login', $this->buildAuthViewData('login')); + } + + return $this->viewFactory->make( + modularityBaseKey() . '::auth.login', + $this->buildAuthViewData($this->mfaChallengePageKey()) + ); } /** @@ -103,18 +123,12 @@ protected function authenticated(Request $request, $user) protected function afterAuthentication(Request $request, $user) { - // dd('here',$user->google_2fa_secret && $user->google_2fa_enabled); - - if ($user->google_2fa_secret && $user->google_2fa_enabled) { - $this->guard()->logout(); - - $request->session()->put('2fa:user:id', $user->id); + if ($mfaResponse = $this->enforceMfaSetupOnLogin($request, $user)) { + return $mfaResponse; + } - return $request->wantsJson() - ? new JsonResponse([ - 'redirector' => $this->redirector->to(route(Route::hasAdmin('admin.login-2fa.form')))->getTargetUrl(), - ]) - : $this->redirector->to(route(Route::hasAdmin('admin.login-2fa.form'))); + if ($mfaChallenge = $this->startMfaChallenge($request, $user)) { + return $mfaChallenge; } $previousRouteName = previous_route_name(); @@ -140,35 +154,23 @@ protected function afterAuthentication(Request $request, $user) } - /** - * @return RedirectResponse - * - * @throws IncompatibleWithGoogleAuthenticatorException - * @throws InvalidCharactersException - * @throws SecretKeyTooShortException - */ public function login2Fa(Request $request) { - $userId = $request->session()->get('2fa:user:id'); - - $user = User::findOrFail($userId); - - $valid = (new Google2FA)->verifyKey( - $user->google_2fa_secret, - $request->input('verify-code') - ); + if (! $this->isMfaEnabled()) { + return $this->redirector->to(route(Route::hasAdmin('login.form'))); + } - if ($valid) { - $this->authManager->guard(Modularity::getAuthGuardName())->loginUsingId($userId); + $user = $this->resolveMfaUserFromSession($request); - $request->session()->pull('2fa:user:id'); + if (! $user) { + return $this->mfaFailureResponse($request, 'Your MFA session has expired. Please login again.'); + } - return $this->redirector->intended($this->redirectTo); + if (! $this->validateMfaOtp($user, $request)) { + return $this->mfaFailureResponse($request, 'Your one time password is invalid.'); } - return $this->redirector->to(route(Route::hasAdmin('admin.login-2fa.form')))->withErrors([ - 'error' => 'Your one time password is invalid.', - ]); + return $this->completeMfaLogin($request, $user); } public function redirectTo() @@ -184,11 +186,15 @@ public function redirectTo() protected function sendFailedLoginResponse(Request $request) { if ($request->wantsJson()) { - return new JsonResponse([ + $errors = [ $this->username() => [trans('auth.failed')], + ]; + + return new JsonResponse([ + 'errors' => $errors, 'message' => __('auth.failed'), 'variant' => MessageStage::WARNING, - ], 200); + ], 422); } throw ValidationException::withMessages([ diff --git a/src/Http/Controllers/Auth/RegisterController.php b/src/Http/Controllers/Auth/RegisterController.php index 581f2cfb8..e75767eea 100755 --- a/src/Http/Controllers/Auth/RegisterController.php +++ b/src/Http/Controllers/Auth/RegisterController.php @@ -86,7 +86,7 @@ protected function register(Request $request) 'language' => $request['language'] ?? app()->getLocale(), ]); - $user->assignRole('client-manager'); + $user->assignRole(modularityConfig('default_register_role')); event(new ModularityUserRegistered($user, $request)); diff --git a/src/Http/Controllers/Auth/StepUpController.php b/src/Http/Controllers/Auth/StepUpController.php new file mode 100644 index 000000000..334ac0a52 --- /dev/null +++ b/src/Http/Controllers/Auth/StepUpController.php @@ -0,0 +1,49 @@ +stepUpService->hasActiveChallenge(request())) { + return redirect()->route(Route::hasAdmin('dashboard')); + } + + return $this->viewFactory->make( + modularityBaseKey() . '::auth.login', + $this->buildAuthViewData($this->stepUpService->pageKey(), [ + 'formAttributes' => [ + 'subtitle' => __('We sent a verification code to your email.'), + ], + ]) + ); + } + + public function verify(Request $request) + { + return $this->stepUpService->verify($request); + } + + public function resend(Request $request) + { + return $this->stepUpService->resend($request); + } +} diff --git a/src/Http/Controllers/BaseController.php b/src/Http/Controllers/BaseController.php index d453adfab..d7d4b3c5e 100755 --- a/src/Http/Controllers/BaseController.php +++ b/src/Http/Controllers/BaseController.php @@ -7,14 +7,16 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Lang; use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\View; use Illuminate\Support\Str; +use Modules\Cms\Http\Controllers\Traits\ManageCms; +use Unusualify\Modularity\Http\Controllers\Traits\ManageBulkSheet; use Unusualify\Modularity\Http\Controllers\Traits\ManageIndexAjax; use Unusualify\Modularity\Http\Controllers\Traits\ManageInertia; +use Unusualify\Modularity\Http\Controllers\Traits\ManagePreview; use Unusualify\Modularity\Http\Controllers\Traits\ManagePrevious; use Unusualify\Modularity\Http\Controllers\Traits\ManageSingleton; use Unusualify\Modularity\Http\Controllers\Traits\ManageTranslations; @@ -23,7 +25,15 @@ abstract class BaseController extends PanelController { - use ManageIndexAjax, ManagePrevious, ManageUtilities, ManageSingleton, ManageInertia, ManageTranslations; + use ManageBulkSheet, + ManageIndexAjax, + ManagePrevious, + ManageUtilities, + ManageSingleton, + ManageInertia, + ManageTranslations, + ManagePreview, + ManageCms; /** * @var string @@ -50,25 +60,9 @@ public function __construct( protected function getViewPrefix(): ?string { - $module_prefix = Str::snake($this->moduleName); + $prefix = $this->presentationViewPrefix(); - $route_prefix = Str::snake($this->routeName); - - // dd($module_prefix, $route_prefix); - - return "$module_prefix::$route_prefix"; - - $prefix = "admin.$this->moduleName"; - - if (view()->exists("$prefix.form")) { - return $prefix; - } - - // try { - // return TwillCapsules::getCapsuleForModel($this->modelName)->getViewPrefix(); - // } catch (NoCapsuleFoundException $e) { - // return null; - // } + return $prefix !== '' ? $prefix : null; } public function preload() @@ -308,6 +302,7 @@ public function update($id, $submoduleId = null) if ($this->isSingleton) { $item = $this->repository->getModel()->single(); + $id = $this->getItemIdentifier($item); } else { $item = $this->repository->getById($id); } @@ -323,6 +318,7 @@ public function update($id, $submoduleId = null) $formRequest = $this->validateFormRequest(); $this->repository->update($id, $formRequest->all(), $this->getPreviousRouteSchema()); + $item = $this->repository->getById($id); // $this->handleActionEvent($item, __FUNCTION__); @@ -368,9 +364,7 @@ public function update($id, $submoduleId = null) ]); if ($this->routeHasTrait('revisions')) { - return Response::json([ - 'message' => $message, - 'variant' => MessageStage::SUCCESS, + return $this->respondWithSuccess($message, [ 'revisions' => $item->revisionsArray(), ]); } diff --git a/src/Http/Controllers/DashboardController.php b/src/Http/Controllers/DashboardController.php index 91e92dc3b..4baf3cf1b 100755 --- a/src/Http/Controllers/DashboardController.php +++ b/src/Http/Controllers/DashboardController.php @@ -33,7 +33,6 @@ public function __construct(Application $app, Request $request) $request ); - $this->removeMiddleware("can:{$this->permissionPrefix()}_" . Permission::VIEW->value); $this->middleware('can:dashboard', ['only' => ['index']]); } diff --git a/src/Http/Controllers/PanelController.php b/src/Http/Controllers/PanelController.php index ad3732904..5571360cd 100644 --- a/src/Http/Controllers/PanelController.php +++ b/src/Http/Controllers/PanelController.php @@ -114,20 +114,6 @@ abstract class PanelController extends CoreController implements CacheableInterf */ protected $indexOptions; - /** - * Relation count to eager load for the form view. - * - * @var array - */ - protected $formWithCount = []; - - /** - * formSchema - * - * @var array - */ - protected $formSchema; - /** * List of permissions keyed by a request field. Can be used to prevent unauthorized field updates. * @@ -213,7 +199,7 @@ public function __construct( }); // $this->setMiddlewareBasePermission(); - $this->setMiddlewarePermission(); + $this->setMiddlewarePermissions(); // $this->titleColumnKey = $this->getConfigFieldsByRoute('title_column_key', 'name'); @@ -274,39 +260,48 @@ public function removeMiddleware($middleware) } } - protected function permissionPrefix($permission = '') + protected function addMiddlewarePermissions() { - return $this->getKebabCase($this->routeName) . ($permission != '' ? "_{$permission}" : ''); + foreach ($this->traitsMethods(__FUNCTION__) as $method) { + $this->$method(); + } } - protected function setMiddlewarePermission() + protected function setMiddlewarePermission($permission, $options) { + $this->middleware($this->module->generatePermissionMiddlewareDefinition($permission, $this->routeName), $options); + } - // Permission::where('name', 'LIKE', "%{$this->getKebabCase($this->routeName)}%")->get(), + protected function setMiddlewarePermissions() + { + if ($this->isGateable() && $this->setDefaultPermissions) { - $name = $this->getKebabCase($this->routeName); - // foreach ( Permission::cases() as $permission) { - // // $this->middleware("can:{$name}_{$permission->value}", ['only' => ['index', 'show']]); - // } + if($this->module) { + $permissions = [ + 'VIEW' => [ 'only' => ['index', 'show']], + 'CREATE' => [ 'only' => ['create', 'store']], + 'EDIT' => [ 'only' => ['edit', 'update']], + 'DELETE' => [ 'only' => ['delete']], + 'FORCEDELETE' => [ 'only' => ['forceDelete']], + 'RESTORE' => [ 'only' => ['restore']], + 'DUPLICATE' => [ 'only' => ['duplicate']], + 'REORDER' => [ 'only' => ['reorder']], + + // 'LIST' => [ 'only' => ['index', 'show']], + // 'EDIT' => [ 'only' => ['edit', 'update']], + // 'DUPLICATE' => [ 'only' => ['duplicate']], + // 'PUBLISH' => [ 'only' => ['publish', 'feature', 'bulkPublish', 'bulkFeature']], + // 'REORDER' => [ 'only' => ['reorder']], + // 'DELETE' => [ 'only' => ['destroy', 'bulkDelete', 'restore', 'bulkRestore', 'forceDelete', 'bulkForceDelete', 'restoreRevision']], + + ]; + foreach ($permissions as $permission => $options) { + $this->setMiddlewarePermission($permission, $options); + } + } - // dd(Permission::ACCESS->value, $name); - if ($this->isGateable() && $this->setDefaultPermissions) { - $this->middleware("can:{$this->permissionPrefix(Permission::VIEW->value)}", ['only' => ['index', 'show']]); - $this->middleware("can:{$this->permissionPrefix(Permission::CREATE->value)}", ['only' => ['create', 'store']]); - $this->middleware("can:{$this->permissionPrefix(Permission::EDIT->value)}", ['only' => ['edit', 'update']]); - $this->middleware("can:{$this->permissionPrefix(Permission::DELETE->value)}", ['only' => ['delete']]); - $this->middleware("can:{$this->permissionPrefix(Permission::FORCEDELETE->value)}", ['only' => ['forceDelete']]); - $this->middleware("can:{$this->permissionPrefix(Permission::RESTORE->value)}", ['only' => ['restore']]); - $this->middleware("can:{$this->permissionPrefix(Permission::DUPLICATE->value)}", ['only' => ['duplicate']]); - $this->middleware("can:{$this->permissionPrefix(Permission::REORDER->value)}", ['only' => ['reorder']]); + $this->addMiddlewarePermissions(); } - - // $this->middleware('can:list', ['only' => ['index', 'show']]); - // $this->middleware('can:edit', ['only' => ['store', 'edit', 'update']]); - // $this->middleware('can:duplicate', ['only' => ['duplicate']]); - // $this->middleware('can:publish', ['only' => ['publish', 'feature', 'bulkPublish', 'bulkFeature']]); - // $this->middleware('can:reorder', ['only' => ['reorder']]); - // $this->middleware('can:delete', ['only' => ['destroy', 'bulkDelete', 'restore', 'bulkRestore', 'forceDelete', 'bulkForceDelete', 'restoreRevision']]); } protected function checkNestedAttributes() @@ -394,21 +389,21 @@ protected function getIndexOption($option) : $option; $authorizableOptions = [ - 'index' => $this->permissionPrefix(Permission::VIEW->value), - 'create' => $this->permissionPrefix(Permission::CREATE->value), - 'edit' => $this->permissionPrefix(Permission::EDIT->value), - 'delete' => $this->permissionPrefix(Permission::DELETE->value), - 'destroy' => $this->permissionPrefix(Permission::DELETE->value), - - 'restore' => $this->permissionPrefix(Permission::RESTORE->value), - 'forceDelete' => $this->permissionPrefix(Permission::FORCEDELETE->value), - 'duplicate' => $this->permissionPrefix(Permission::DUPLICATE->value), - 'activity' => $this->permissionPrefix(Permission::ACTIVITY->value), - 'show' => $this->permissionPrefix(Permission::SHOW->value), + 'index' => $this->module->generatePermissionName(Permission::VIEW->value, $this->routeName), + 'create' => $this->module->generatePermissionName(Permission::CREATE->value, $this->routeName), + 'edit' => $this->module->generatePermissionName(Permission::EDIT->value, $this->routeName), + 'delete' => $this->module->generatePermissionName(Permission::DELETE->value, $this->routeName), + 'destroy' => $this->module->generatePermissionName(Permission::DELETE->value, $this->routeName), + + 'restore' => $this->module->generatePermissionName(Permission::RESTORE->value, $this->routeName), + 'forceDelete' => $this->module->generatePermissionName(Permission::FORCEDELETE->value, $this->routeName), + 'duplicate' => $this->module->generatePermissionName(Permission::DUPLICATE->value, $this->routeName), + 'activity' => $this->module->generatePermissionName(Permission::ACTIVITY->value, $this->routeName), + 'show' => $this->module->generatePermissionName(Permission::SHOW->value, $this->routeName), /** * TODO #additionalRoutePermission */ - // 'duplicate' => $this->permissionPrefix(Permission::DUPLICATE->value), + // 'duplicate' => $this->module->generatePermissionName(Permission::DUPLICATE->value, $this->routeName), // 'index' => 'access', // 'create' => 'edit', diff --git a/src/Http/Controllers/ProfileController.php b/src/Http/Controllers/ProfileController.php index 24f6ef3f6..fb75bd484 100755 --- a/src/Http/Controllers/ProfileController.php +++ b/src/Http/Controllers/ProfileController.php @@ -51,21 +51,6 @@ public function __construct( $request ); - // dd( - // "can:{$this->permissionPrefix(Permission::VIEW->value)}", - // "can:{$this->permissionPrefix(Permission::EDIT->value)}", - // $this->middleware, - // get_class_methods($this), - // $this - // ); - $this->removeMiddleware("can:{$this->permissionPrefix(Permission::VIEW->value)}"); - $this->removeMiddleware("can:{$this->permissionPrefix(Permission::EDIT->value)}"); - // dd( - // $this->middleware - // ); - // $this->removeMiddleware("can:{$this->permissionPrefix()}_". Permission::VIEW->value); - // $this->middleware('can:dashboard', ['only' => ['index']]); - } public function edit($id = null, $submoduleId = null) diff --git a/src/Http/Controllers/Traits/ApiAuthentication.php b/src/Http/Controllers/Traits/API/ApiAuthentication.php similarity index 95% rename from src/Http/Controllers/Traits/ApiAuthentication.php rename to src/Http/Controllers/Traits/API/ApiAuthentication.php index c25c5a086..1797b3156 100644 --- a/src/Http/Controllers/Traits/ApiAuthentication.php +++ b/src/Http/Controllers/Traits/API/ApiAuthentication.php @@ -1,6 +1,6 @@ mapWithKeys(function($v, $k){return is_numeric($k) ? [$v => true] : [$k => $v];}); - // $default_input = $this->configureInput(array_to_object(Config::get(modularityBaseKey() . '.default_input'))); $default_input = (array) Config::get(modularityBaseKey() . '.default_input'); $input = transform_closure_values($input, forceArray: true); @@ -123,12 +135,6 @@ protected function getSchemaInput($input, $inputs = []) } return (bool) $name ? [$name => $_input] : []; - - return isset($name) - // ? [ $input->name => $default_input->union( $this->configureInput($input) ) ] - // ? [ $input['name'] => array_merge_recursive_preserve( $default_input, $this->configureInput($input) ) ] - ? [$hydrated['name'] => $this->configureInput(array_merge_recursive_preserve($default_input, $hydrated))] - : (in_array($type, ['title', 'divider']) ? [$type . '_' . uniqid() => $hydrated] : []); } /** @@ -773,11 +779,11 @@ public function hydrateInputExtension(&$input, &$data, &$arrayable, $inputs) } $methodName = array_shift($args); - // [$methodName, $formattedInput, $parentColumnName] = array_pad(explode(':',$pattern), 3, null); + $targetInputName = array_shift($args); $changers = []; + switch ($methodName) { case 'permalinkPrefix': // 'permalinkPrefix:slug', - $inputToFormat = array_shift($args); if (isset($input['repository'])) { foreach ($this->getConfigFieldsByRoute('inputs') as $key => $_input) { if (isset($_input->ext) && in_array(explode(':', $_input->ext)[0], ['permalink'])) { @@ -785,18 +791,16 @@ public function hydrateInputExtension(&$input, &$data, &$arrayable, $inputs) } } } else { - $events[] = 'formatPermalinkPrefix:' . $inputToFormat . ':' . $this->getSnakeCase($this->getRouteName()); + $events[] = 'formatPermalinkPrefix:' . $targetInputName . ':' . $this->getSnakeCase($this->getRouteName()); } break; case 'lock': // 'lock:url:url' - $inputToFormat = array_shift($args); $parentColumnName = array_shift($args); - $events[] = "formatLock:{$inputToFormat}:{$parentColumnName}"; + $events[] = "formatLock:{$targetInputName}:{$parentColumnName}"; break; case 'permalink': // 'permalink:slug', - $inputToFormat = array_shift($args); $permalinkPrefix = getHost() . '/'; $permalinkPrefixFormat = getHost() . '/'; foreach ($inputs as $_input) { @@ -829,11 +833,10 @@ public function hydrateInputExtension(&$input, &$data, &$arrayable, $inputs) 'readonly' => true, ]); unset($input['ext']); - $events[] = 'formatPermalink:' . $inputToFormat; + $events[] = 'formatPermalink:' . $targetInputName; break; case 'filter': // 'filter:{target_input_name}:{target_prop_name}:{followed_key_name}' - $inputToFormat = array_shift($args); $targetPropName = array_shift($args) ?? 'inputs'; $filterEndpoint = $input['filterEndpoint'] ?? null; @@ -867,81 +870,78 @@ public function hydrateInputExtension(&$input, &$data, &$arrayable, $inputs) $input['filterEndpoint'] = $filterEndpoint; } - $events[] = 'formatFilter:' . implode(':', [$inputToFormat, $targetPropName, ...$args]); + $events[] = 'formatFilter:' . implode(':', [$targetInputName, $targetPropName, ...$args]); } break; case 'preview': // - $inputToFormat = array_shift($args) ?? ''; $previewFieldPatterns = array_shift($args) ?? null; if ($previewFieldPatterns) { $previewFieldPatterns = ':' . $previewFieldPatterns; } - if ($inputToFormat) { - $events[] = "formatPreview:{$inputToFormat}{$previewFieldPatterns}"; + if ($targetInputName) { + $events[] = "formatPreview:{$targetInputName}{$previewFieldPatterns}"; } break; case 'set': // - $inputToFormat = array_shift($args) ?? ''; - $inputPropToFormat = array_shift($args) ?? null; - $setProp = array_shift($args) ?? "items.*.{$inputPropToFormat}"; + case 'update': // same args as set; frontend runs formatUpdate only when valueChanged (not on initial model hydrate) + $targetPropName = array_shift($args) ?? null; + $setProp = array_shift($args) ?? "items.*.{$targetPropName}"; $modelNotation = array_shift($args) ?? null; $changers = [ 'wrap_location' => [ 'default', - $inputPropToFormat, // schema + $targetPropName, // schema ], ]; - if ($inputToFormat && $inputPropToFormat) { - $events[] = "formatSet:{$inputToFormat}:{$inputPropToFormat}:{$setProp}:{$modelNotation}"; + + $eventName = 'format' . studlyName($methodName); + if ($targetInputName && $targetPropName) { + + $events[] = implode(':', [$eventName, $targetInputName, $targetPropName, $setProp, ...($modelNotation ? [modelNotation] : [])]); } break; case 'clearModel': // - $inputToFormat = array_shift($args) ?? ''; - if ($inputToFormat) { - $events[] = "formatClearModel:{$inputToFormat}"; + if ($targetInputName) { + $events[] = "formatClearModel:{$targetInputName}"; } break; case 'resetItems': // - $inputToFormat = array_shift($args) ?? ''; - if ($inputToFormat) { - $events[] = "formatResetItems:{$inputToFormat}"; + if ($targetInputName) { + $events[] = "formatResetItems:{$targetInputName}"; } break; case 'prependSchema': // - $inputToFormat = array_shift($args) ?? ''; $prependKey = array_shift($args) ?? null; $setterSchemaKey = array_shift($args) ?? null; $orderKey = array_shift($args) ?? 'false'; - if ($inputToFormat && $prependKey && $setterSchemaKey) { - $events[] = "formatPrependSchema:{$inputToFormat}:{$prependKey}:{$setterSchemaKey}:{$orderKey}"; + if ($targetInputName && $prependKey && $setterSchemaKey) { + $events[] = "formatPrependSchema:{$targetInputName}:{$prependKey}:{$setterSchemaKey}:{$orderKey}"; } break; case 'removeValue': // - $inputToFormat = array_shift($args) ?? ''; - if ($inputToFormat) { - $events[] = "formatRemoveValue:{$inputToFormat}"; + if ($targetInputName) { + $events[] = "formatRemoveValue:{$targetInputName}"; } break; case 'toggleInput': // to toggle d-none class and rawRules - $inputToFormat = array_shift($args) ?? ''; $toggleValue = array_shift($args) ?? 'toggleValue'; $toggleLevel = array_shift($args) ?? -1; - if ($inputToFormat) { - $events[] = "formatToggleInput:{$inputToFormat}:{$toggleValue}:{$toggleLevel}"; + if ($targetInputName) { + $events[] = "formatToggleInput:{$targetInputName}:{$toggleValue}:{$toggleLevel}"; } break; @@ -952,13 +952,8 @@ public function hydrateInputExtension(&$input, &$data, &$arrayable, $inputs) } if (! empty($events)) { - $data = (array) ($data ?? $input); try { - // code... - // if($input['name'] == 'packageCountry'){ - // dd($data, $events, explode('|', $data['event'] ?? '')); - // } - $data['event'] = implode('|', array_unique(array_merge($events, isset($data['event']) ? explode('|', $data['event']) : []))); + $input['event'] = implode('|', array_unique(array_merge($events, isset($data['event']) ? explode('|', $data['event']) : []))); } catch (\Throwable $th) { dd($events, $data, $th, $this->config); } diff --git a/src/Http/Controllers/Traits/MakesResponses.php b/src/Http/Controllers/Traits/MakesResponses.php index 5211e73e8..ff1cd7b46 100755 --- a/src/Http/Controllers/Traits/MakesResponses.php +++ b/src/Http/Controllers/Traits/MakesResponses.php @@ -96,6 +96,15 @@ protected function redirectToForm($id, $params = []) )); } + protected function handleResponse($response) + { + foreach ($this->traitsMethods(__FUNCTION__) as $method) { + $response = $this->$method($response); + } + + return $response; + } + /** * @param string $message * @return JsonResponse @@ -111,11 +120,11 @@ protected function respondWithSuccess($message, $attributes = []) */ protected function respondWithRedirect($redirectUrl, $attributes = []) { - return Response::json([ + return $this->handleResponse(Response::json([ 'redirect' => $redirectUrl, 'redirector' => $redirectUrl, ...$attributes, - ]); + ])); } /** @@ -134,10 +143,10 @@ protected function respondWithError($message, $attributes = []) */ protected function respondWithJson($message, $variant, $attributes = []) { - return Response::json([ + return $this->handleResponse(Response::json([ ...$attributes, 'message' => $message, 'variant' => $variant, - ]); + ])); } } diff --git a/src/Http/Controllers/Traits/ManageBulkSheet.php b/src/Http/Controllers/Traits/ManageBulkSheet.php new file mode 100644 index 000000000..41bb63001 --- /dev/null +++ b/src/Http/Controllers/Traits/ManageBulkSheet.php @@ -0,0 +1,463 @@ +module) { + return; + } + + $def = $this->bulkSheetToolbarDefinition(); + if ($def === [] || empty($def['href'])) { + return; + } + + $toolKey = $this->bulkSheetToolKey(); + if ($toolKey === '') { + return; + } + + $position = $def['position'] ?? 'append'; + + $bulkTool = [ + 'toolKey' => $toolKey, + 'sheetFields' => $this->bulkSheetFields(), + ]; + + $tooltipItems = array_map(function ($item) { + return [ + 'label' => ($item['label'] ?? $item['key']) . ($item['required'] ? ' *' : '') . ($item['aliases'] ? ' (' . implode(', ', $item['aliases']) . ')' : ''), + ]; + }, $this->bulkSheetFields()); + + if (! empty($def['intro'])) { + $bulkTool['intro'] = $def['intro']; + } + + $action = [ + 'label' => $def['label'] ?? __('Bulk import / export'), + 'href' => $def['href'], + 'target' => $def['target'] ?? '_self', + 'icon' => $def['icon'] ?? 'mdi-tray-arrow-up', + 'bulkTool' => $bulkTool, + 'tooltipItems' => $tooltipItems, + ]; + + foreach ([ + 'forceLabel', 'density', 'variant', 'color', 'textColor', 'allowedRoles', 'noSuperAdmin', + 'tooltip', 'tooltipLocation', 'componentProps', 'responsive', 'badge', 'badgeColor', + ] as $optionalKey) { + if (array_key_exists($optionalKey, $def)) { + $action[$optionalKey] = $def[$optionalKey]; + } + } + + if (isset($def['table_action_extras']) && is_array($def['table_action_extras'])) { + $action = array_merge($action, $def['table_action_extras']); + } + + $existing = is_array($this->tableActions ?? null) ? $this->tableActions : []; + + $this->tableActions = $position === 'prepend' + ? array_values(array_merge([$action], $existing)) + : array_values(array_merge($existing, [$action])); + } + + /** + * `bulk_sheet` block from the current submodule route config. + * + *
+     * 'bulk_sheet' => [
+     *     'export_download_filename' => 'redirects-export.csv',
+     *     'step_up_ability' => 'redirect.bulk_import',
+     *     'preview_table_columns' => [
+     *         ['title' => 'Line', 'key' => 'line', 'width' => '72px'],
+     *         ['title' => 'OK', 'key' => 'valid', 'sortable' => false],
+     *         ['title' => 'Action', 'key' => 'action'],
+     *         ['title' => 'Locale', 'key' => 'locale'],
+     *         ['title' => 'From', 'key' => 'from_path'],
+     *         ['title' => 'To', 'key' => 'to_path'],
+     *         ['title' => 'Errors', 'key' => 'errors', 'sortable' => false],
+     *         ['title' => 'Warnings', 'key' => 'warnings', 'sortable' => false],
+     *     ],
+     * ]
+     * 
+ * + * @return array + */ + protected function bulkSheetRouteConfig(): array + { + if (! $this->module) { + return []; + } + + return (array) ($this->module->getRawRouteConfig($this->routeName)['bulk_sheet'] ?? []); + } + + /** + * Full raw route config row (e.g. url, headline, inputs, bulk_sheet). + * + * @return array + */ + protected function bulkSheetParentRouteConfig(): array + { + if (! $this->module) { + return []; + } + + return (array) $this->module->getRawRouteConfig($this->routeName); + } + + /** + * Tool key for the bulk sheet. + */ + public function bulkSheetToolKey(): string + { + $override = $this->bulkSheetRouteConfig()['tool_key'] ?? null; + if (is_string($override) && $override !== '') { + return $override; + } + + $mod = Str::lower((string) $this->moduleName); + $route = Str::snake(Str::studly((string) $this->routeName)); + + return $mod . '.' . $route; + } + + /** + * Suggested filename for Content-Disposition on export. + */ + public function bulkSheetExportDownloadFilename(): string + { + $cfg = $this->bulkSheetRouteConfig()['export_download_filename'] ?? null; + + return is_string($cfg) && $cfg !== '' ? $cfg : $this->moduleName . '-' . $this->routeName . '-export.csv'; + } + + /** + * Submodule-specific UI strings for {@see BulkSheet.vue} (server-resolved, overrides vue `messages.bulk.*`). + * Keys: intro, columns, csv_file, … (see page props). + * + * @return array + */ + protected function bulkSheetInertiaUiStrings(): array + { + return []; + } + + protected function resolveBulkSheetIntro(): string + { + $ui = $this->bulkSheetInertiaUiStrings(); + if (isset($ui['intro']) && is_string($ui['intro']) && $ui['intro'] !== '') { + return $ui['intro']; + } + + $cfg = $this->bulkSheetRouteConfig(); + $intro = $cfg['toolbar_intro'] ?? null; + if (is_string($intro) && $intro !== '') { + return __($intro); + } + + return $this->bulkSheetToolbarIntroFallback(); + } + + /** + * @return array + */ + protected function bulkSheetUiPropsForInertia(): array + { + $merged = $this->bulkSheetInertiaUiStrings(); + $merged['intro'] = $this->resolveBulkSheetIntro(); + + return array_filter($merged, static fn ($v) => is_string($v) && $v !== ''); + } + + /** + * @return array + */ + public function bulkSheetToolbarDefinition(): array + { + if (! $this->module) { + return []; + } + + $prefix = $this->module->panelRouteNamePrefix() . '.'; + $cfg = $this->bulkSheetRouteConfig(); + $names = $this->bulkSheetWebRouteNames(); + $label = $cfg['toolbar_label'] ?? null; + + return [ + 'position' => $cfg['toolbar_position'] ?? 'append', + 'label' => is_string($label) && $label !== '' ? __($label) : $this->bulkSheetToolbarLabel(), + 'icon' => $cfg['toolbar_icon'] ?? 'mdi-tray-arrow-up', + 'variant' => $cfg['toolbar_variant'] ?? 'tonal', + 'color' => $cfg['toolbar_color'] ?? 'secondary', + 'href' => $prefix . Str::snake($this->routeName) . '.' . $names['tool'], + 'target' => $cfg['toolbar_target'] ?? '_self', + 'intro' => $this->resolveBulkSheetIntro(), + ]; + } + + /** + * @return array{tool: string, dryRun: string, commit: string, export: string} + */ + protected function defaultBulkSheetWebRouteNames(): array + { + return [ + 'tool' => 'bulk.tool', + 'dryRun' => 'bulk.dryRun.web', + 'commit' => 'bulk.commit.web', + 'export' => 'bulk.export.web', + ]; + } + + /** + * @return array{tool: string, dryRun: string, commit: string, export: string} + */ + public function bulkSheetWebRouteNames(): array + { + $cfg = $this->bulkSheetRouteConfig()['web_route_names'] ?? []; + $defaults = $this->defaultBulkSheetWebRouteNames(); + + return [ + 'tool' => (string) ($cfg['tool'] ?? $defaults['tool']), + 'dryRun' => (string) ($cfg['dryRun'] ?? $defaults['dryRun']), + 'commit' => (string) ($cfg['commit'] ?? $defaults['commit']), + 'export' => (string) ($cfg['export'] ?? $defaults['export']), + ]; + } + + public function bulkSheetStepUpAbility(): ?string + { + $v = $this->bulkSheetRouteConfig()['step_up_ability'] ?? null; + + return is_string($v) && $v !== '' ? $v : null; + } + + public function bulkSheetToolHeadline(): string + { + $ui = $this->bulkSheetInertiaUiStrings(); + if (isset($ui['headline']) && is_string($ui['headline']) && $ui['headline'] !== '') { + return $ui['headline']; + } + + $h = $this->bulkSheetRouteConfig()['tool_headline'] ?? null; + if (is_string($h) && $h !== '') { + return __($h); + } + + return $this->bulkSheetToolHeadlineFallback(); + } + + public function bulkSheetToolBrowserTitle(): string + { + $ui = $this->bulkSheetInertiaUiStrings(); + if (isset($ui['browser_title']) && is_string($ui['browser_title']) && $ui['browser_title'] !== '') { + return $ui['browser_title']; + } + + $t = $this->bulkSheetRouteConfig()['browser_title'] ?? null; + if (is_string($t) && $t !== '') { + return __($t); + } + + return $this->bulkSheetToolHeadline(); + } + + public function bulkSheetPreviewTableColumns(): ?array + { + $cols = $this->bulkSheetRouteConfig()['preview_table_columns'] ?? null; + + return is_array($cols) && $cols !== [] ? $cols : null; + } + + protected function bulkSheetToolbarLabel(): string + { + return __('Import / export (CSV)'); + } + + private function bulkSheetToolbarIntroFallback(): string + { + return __('messages.bulk.intro'); + } + + private function bulkSheetToolHeadlineFallback(): string + { + return __('messages.bulk.headline'); + } + + /** + * GET — Inertia shell for CSV import/export (POST targets {@see bulkSheetDryRun} / {@see bulkSheetCommit}). + */ + public function bulkSheetTool(Request $request): Response + { + if (! $this instanceof CanBulkSheet || ! $this->module) { + abort(404); + } + + $this->shareInertiaStoreVariables(); + + $prefix = $this->module->panelRouteNamePrefix() . '.' . Str::snake($this->routeName) . '.'; + $names = $this->bulkSheetWebRouteNames(); + $toolKey = $this->bulkSheetToolKey(); + + $pageTitle = $this->bulkSheetToolBrowserTitle() . ' - ' . Modularity::pageTitle(); + $data = [ + 'pageTitle' => $pageTitle, + 'headerTitle' => $this->bulkSheetToolHeadline(), + '_mainConfiguration' => [ + 'navigation' => $this->bulkSheetNavigationWithBreadcrumbs(), + ], + ]; + + return Inertia::render($this->bulkSheetInertiaComponent(), [ + 'toolKey' => $toolKey, + 'bulkToolSheet' => $this->bulkSheetFields(), + 'bulkSheetUi' => $this->bulkSheetUiPropsForInertia(), + 'bulkSheetEndpoints' => [ + 'dryRun' => route($prefix . $names['dryRun']), + 'commit' => route($prefix . $names['commit']), + 'export' => route($prefix . $names['export']), + ], + 'bulkSheetPreviewTableColumns' => $this->bulkSheetPreviewTableColumns(), + 'endpoints' => new \stdClass, + 'mainConfiguration' => $this->getInertiaMainConfiguration($data), + 'headLayoutData' => $this->getHeadLayoutData($data), + ]); + } + + final public function bulkSheetDryRun(Request $request): JsonResponse + { + if (! $this instanceof CanBulkSheet || ! $this->module) { + abort(404); + } + + $data = $request->validate([ + 'csv' => 'required|string|max:2097152', + 'tool_key' => ['nullable', 'string', 'max:128'], + ]); + + $toolKey = (string) ($data['tool_key'] ?? $this->bulkSheetToolKey()); + $this->assertBulkSheetToolKey($toolKey); + + /** @var BulkImportService $bulk */ + $bulk = $this->app->make(BulkImportService::class); + + return response()->json($bulk->import($data['csv'], true, $this, $toolKey)); + } + + final public function bulkSheetCommit(Request $request): JsonResponse + { + if (! $this instanceof CanBulkSheet || ! $this->module) { + abort(404); + } + + $data = $request->validate([ + 'csv' => 'required|string|max:2097152', + 'tool_key' => ['nullable', 'string', 'max:128'], + ]); + + $toolKey = (string) ($data['tool_key'] ?? $this->bulkSheetToolKey()); + $this->assertBulkSheetToolKey($toolKey); + + /** @var BulkImportService $bulk */ + $bulk = $this->app->make(BulkImportService::class); + + return response()->json($bulk->import($data['csv'], false, $this, $toolKey)); + } + + final public function bulkSheetExport(Request $request): StreamedResponse + { + if (! $this instanceof CanBulkSheet || ! $this->module) { + abort(404); + } + + $request->validate([ + 'tool_key' => ['nullable', 'string', 'max:128'], + ]); + + $toolKey = (string) $request->input('tool_key', $this->bulkSheetToolKey()); + $this->assertBulkSheetToolKey($toolKey); + + /** @var BulkImportService $bulk */ + $bulk = $this->app->make(BulkImportService::class); + + return $bulk->streamExport($this); + } + + /** + * Inertia page component (under `Pages/`), without path or extension. + */ + protected function bulkSheetInertiaComponent(): string + { + return 'BulkSheet'; + } + + /** + * @return array + */ + protected function bulkSheetNavigationWithBreadcrumbs(): array + { + $navigation = get_modularity_navigation_config(); + $navigation['breadcrumbs'] = $this->bulkSheetBreadcrumbsItems(); + + return $navigation; + } + + /** + * @return list + */ + protected function bulkSheetBreadcrumbsItems(): array + { + $parentRouteName = $this->module->panelRouteNamePrefix() . '.index'; + $routeName = $this->module->panelRouteNamePrefix() . '.' . snakeCase($this->routeName) . '.index'; + + return [ + [ + 'title' => headline($this->moduleName), + 'href' => Route::has($parentRouteName) ? route($parentRouteName) : null, + ], + [ + 'title' => headline($this->routeName), + 'href' => Route::has($routeName) ? route($routeName) : null, + ], + [ + 'title' => $this->bulkSheetToolHeadlineFallback(), + 'disabled' => true, + ], + ]; + } + + protected function assertBulkSheetToolKey(string $toolKey): void + { + if ($toolKey !== $this->bulkSheetToolKey()) { + abort(403, 'Invalid bulk sheet tool for this route.'); + } + } +} diff --git a/src/Http/Controllers/Traits/ManagePreview.php b/src/Http/Controllers/Traits/ManagePreview.php new file mode 100644 index 000000000..9987fa8f7 --- /dev/null +++ b/src/Http/Controllers/Traits/ManagePreview.php @@ -0,0 +1,169 @@ +module && $this->routeHasTrait('revisions')) { + $permissions = [ + 'REVISION_RESTORE' => ['only' => ['restoreRevision']], + 'REVISION_APPROVE' => ['only' => ['approveRevision']], + 'REVISION_REJECT' => ['only' => ['rejectRevision']], + ]; + + foreach ($permissions as $permission => $options) { + $this->setMiddlewarePermission($permission, $options); + } + } + } + + public function previewData($item) + { + return []; + } + + /** + * Apply locale before {@see preview} / {@see previewForRevision} so hydration and Blade use the same language. + * Query: activeLanguage (preferred) or locale. + */ + protected function applyPreviewRequestLocale(): void + { + if ($this->request->filled('activeLanguage')) { + App::setLocale((string) $this->request->get('activeLanguage')); + + return; + } + + if ($this->request->filled('locale')) { + App::setLocale((string) $this->request->get('locale')); + } + } + + public function showView($id) + { + $this->applyPreviewRequestLocale(); + + if ($this->request->has('revisionId')) { + $item = $this->repository->previewForRevision($id, $this->request->get('revisionId'), $this->formSchema); + } else { + $formRequest = $this->validateFormRequest(); + $item = $this->repository->preview($id, $formRequest->all()); + } + + $previewView = $this->presentationViewName(); + + return View::exists($previewView) ? View::make( + $previewView, + array_replace([ + 'item' => $item, + ], $this->previewData($item)) + ) : View::make('twill::errors.preview', [ + 'moduleName' => Str::singular($this->moduleName), + ]); + } + + public function listRevisions($id) + { + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $object = $this->repository->getModel()->newQuery()->findOrFail($id); + + return $object->revisionsArray(); + } + + public function restoreRevision($id) + { + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + // dd($revisionId); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + if ($this->request->get('preview')) { + // dd("preview is called for revision id: $revisionId"); + $rawPayload = $this->repository->getRevisionPayload((int) $id, $revisionId); + + return Response::json([ + 'form_fields' => $rawPayload, + ]); + } + + $item = $this->repository->restoreRevision((int) $id, $revisionId); + // dd($item); + + return Response::json([ + 'message' => __('Revision restored successfully.'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } + + public function approveRevision($id) + { + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + $item = $this->repository->approveRevision((int) $id, $revisionId); + + return Response::json([ + 'message' => __('messages.revision.approved-success'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } + + public function rejectRevision($id) + { + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + $item = $this->repository->rejectRevision((int) $id, $revisionId); + + return Response::json([ + 'message' => __('messages.revision.rejected-success'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } +} diff --git a/src/Http/Controllers/Traits/ManageUtilities.php b/src/Http/Controllers/Traits/ManageUtilities.php index c3a874b1c..9e6e8ddbc 100755 --- a/src/Http/Controllers/Traits/ManageUtilities.php +++ b/src/Http/Controllers/Traits/ManageUtilities.php @@ -3,7 +3,6 @@ namespace Unusualify\Modularity\Http\Controllers\Traits; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; use Unusualify\Modularity\Facades\Modularity; @@ -172,6 +171,11 @@ protected function getFormData($id = null) 'actionUrl' => $this->getFormUrl($itemId), 'schema' => $eventualSchema, 'languages' => getLanguagesForVueStore($eventualSchema, $translate)['all'] ?? [], + 'revisions' => $this->routeHasTrait('revisions') && $item ? $item->revisionsArray() : [], + 'restoreUrl' => Route::has($restoreRouteName) && $itemId ? moduleRoute($this->moduleName, $this->routePrefix, 'restoreRevision', [$itemId]) : null, + 'previewUrl' => Route::has($previewRouteName) && $itemId ? moduleRoute($this->moduleName, $this->routePrefix, 'preview', [$itemId]) : null, + 'localizedPublicPermalinks' => $this->localizedPublicPermalinksForFormItem($item), + 'signedPublicPreview' => $this->signedPublicPreviewFormPayload($itemId), ], $formAttributes), 'endpoints' => [ ($isEditing ? 'update' : 'store') => $this->getFormUrl($itemId), diff --git a/src/Http/Controllers/Traits/Table/TableActions.php b/src/Http/Controllers/Traits/Table/TableActions.php index 35fe8cf7d..49c122cd2 100755 --- a/src/Http/Controllers/Traits/Table/TableActions.php +++ b/src/Http/Controllers/Traits/Table/TableActions.php @@ -55,6 +55,10 @@ protected function setTableActions() } $this->tableActions = array_merge_recursive_preserve($tableActions, $this->tableActions ?? []); + + foreach ($this->traitsMethods(__FUNCTION__) as $method) { + $this->{$method}(); + } } public function getTableActions(): array diff --git a/src/Http/Controllers/Traits/Table/TableBulkActions.php b/src/Http/Controllers/Traits/Table/TableBulkActions.php index 17fa58acf..ccfe8a8e3 100755 --- a/src/Http/Controllers/Traits/Table/TableBulkActions.php +++ b/src/Http/Controllers/Traits/Table/TableBulkActions.php @@ -13,35 +13,38 @@ protected function getTableBulkActions(): array { $actions = []; - if ($this->getIndexOption('delete')) { - $actions[] = [ - 'name' => 'bulkDelete', - 'can' => $this->permissionPrefix(Permission::DELETE->value), - 'icon' => '$delete', - // 'color' => 'red darken-2', - 'color' => 'primary', - ]; + if($this->module) { + if ($this->getIndexOption('delete')) { + $actions[] = [ + 'name' => 'bulkDelete', + 'can' => $this->module->generatePermissionMiddlewareDefinition(Permission::DELETE->value, $this->routeName), + 'icon' => '$delete', + // 'color' => 'red darken-2', + 'color' => 'primary', + ]; + } + + if ($this->getIndexOption('forceDelete')) { + $actions[] = [ + 'name' => 'bulkForceDelete', + 'icon' => '$delete', + 'can' => 'forceDelete', + // 'color' => 'red darken-2', + 'color' => 'red', + ]; + } + + if ($this->getIndexOption('restore')) { + $actions[] = [ + 'name' => 'bulkRestore', + 'icon' => '$restore', + 'can' => 'restore', + // 'color' => 'red darken-2', + 'color' => 'green', + ]; + } } - if ($this->getIndexOption('forceDelete')) { - $actions[] = [ - 'name' => 'bulkForceDelete', - 'icon' => '$delete', - 'can' => 'forceDelete', - // 'color' => 'red darken-2', - 'color' => 'red', - ]; - } - - if ($this->getIndexOption('restore')) { - $actions[] = [ - 'name' => 'bulkRestore', - 'icon' => '$restore', - 'can' => 'restore', - // 'color' => 'red darken-2', - 'color' => 'green', - ]; - } return $actions; } diff --git a/src/Http/Controllers/Traits/Table/TableItem.php b/src/Http/Controllers/Traits/Table/TableItem.php index b3c8385e4..839e8ad66 100644 --- a/src/Http/Controllers/Traits/Table/TableItem.php +++ b/src/Http/Controllers/Traits/Table/TableItem.php @@ -509,4 +509,9 @@ public function getFormattedIndexItems(AbstractPaginator $paginator) // getIndex return $paginator->toArray(); } + + public function addIndexAppendsTableItem(): array + { + return $this->getConfigFieldsByRoute('index_appends', []); + } } diff --git a/src/Http/Controllers/Traits/Table/TableRows.php b/src/Http/Controllers/Traits/Table/TableRows.php index d1b9180d6..29dd0f022 100755 --- a/src/Http/Controllers/Traits/Table/TableRows.php +++ b/src/Http/Controllers/Traits/Table/TableRows.php @@ -19,255 +19,257 @@ trait TableRows protected function getTableRowActions() { $tableActions = []; - - $noDefaultTableRowActions = $this->getConfigFieldsByRoute('no_default_table_row_actions', false); - - if (! $noDefaultTableRowActions) { - $model = $this->repository->getModel(); - // if $this->repository has hasPayment - if (classHasTrait($this->repository->getModel(), 'Unusualify\Modularity\Entities\Traits\HasPayment')) { - $tableActions[] = [ - 'name' => 'pay', - 'icon' => 'mdi-contactless-payment', - 'forceLabel' => true, - // 'can' => 'pay', - // 'color' => 'red darken-2', - 'color' => 'success', - 'modalAttributes' => [ - 'title' => __('PAYMENT AND INVOICES'), - 'hasTitleDivider' => true, - ], - 'form' => [ - 'attributes' => [ - 'schema' => $this->createFormSchema($this->repository->getPaymentFormSchema()), - 'actionUrl' => route('admin.system.system_payment.pay'), - 'async' => false, - 'style' => 'height: 70vh !important;', + + if($this->module) { + $noDefaultTableRowActions = $this->getConfigFieldsByRoute('no_default_table_row_actions', false); + + if (! $noDefaultTableRowActions) { + $model = $this->repository->getModel(); + // if $this->repository has hasPayment + if (classHasTrait($this->repository->getModel(), 'Unusualify\Modularity\Entities\Traits\HasPayment')) { + $tableActions[] = [ + 'name' => 'pay', + 'icon' => 'mdi-contactless-payment', + 'forceLabel' => true, + // 'can' => 'pay', + // 'color' => 'red darken-2', + 'color' => 'success', + 'modalAttributes' => [ + 'title' => __('PAYMENT AND INVOICES'), + 'hasTitleDivider' => true, ], - 'model_formatter' => [ - 'price_id' => 'payment_price.id', // lodash get method - // 'price_id' => 'paymentPrice.id', // lodash get method + 'form' => [ + 'attributes' => [ + 'schema' => $this->createFormSchema($this->repository->getPaymentFormSchema()), + 'actionUrl' => route('admin.system.system_payment.pay'), + 'async' => false, + 'style' => 'height: 70vh !important;', + ], + 'model_formatter' => [ + 'price_id' => 'payment_price.id', // lodash get method + // 'price_id' => 'paymentPrice.id', // lodash get method + ], + 'schema_formatter' => [ + 'payment_service.price_object' => 'payment_price', + // 'payment_service.price_object' => 'paymentPrice', + ], ], - 'schema_formatter' => [ - 'payment_service.price_object' => 'payment_price', - // 'payment_service.price_object' => 'paymentPrice', + 'conditions' => [ + // ['state.code', 'in', ['pending-payment']], + // ['payable_price.total_amount', '>', 0], + ['payment.status', 'not in', ['COMPLETED']], + ...(method_exists($model, 'getTableRowConditionsForPayment') + ? $model->getTableRowConditionsForPayment() : []), ], - ], - 'conditions' => [ - // ['state.code', 'in', ['pending-payment']], - // ['payable_price.total_amount', '>', 0], - ['payment.status', 'not in', ['COMPLETED']], - ...(method_exists($model, 'getTableRowConditionsForPayment') - ? $model->getTableRowConditionsForPayment() : []), - ], - // admin.system.system_payment.payment routeName - // admin.crm.template/system/system-payments/pay/{price} - ...(method_exists($model, 'getTableRowPropsForPayment') - ? $model->getTableRowPropsForPayment() : []), - - ]; - // dd($actions); - } - - // duplicate action - if ($this->getIndexOption('duplicate')) { - $tableActions[] = [ - 'name' => 'duplicate', - // 'icon' => '$edit', - 'color' => 'primary darken-2', - ]; - } - - // edit action - if ($this->getIndexOption('edit')) { - $tableActions[] = [ - 'name' => 'edit', - 'color' => 'primary darken-2', - 'can' => $this->permissionPrefix(Permission::EDIT->value), - // 'color' => 'green darken-2', - ]; - } - - // delete action - if ($this->getIndexOption('delete')) { - $tableActions[] = [ - 'name' => 'delete', - 'can' => $this->permissionPrefix(Permission::DELETE->value), - 'variant' => 'outlined', - // 'color' => 'red darken-2', - 'color' => 'error', - ]; - } - - // restore action - if ($this->getIndexOption('restore')) { - $tableActions[] = [ - 'name' => 'restore', - // 'icon' => '$', - 'can' => 'restore', - // 'color' => 'red darken-2', - 'color' => 'green', - ]; - } - - // force delete action - if ($this->getIndexOption('forceDelete')) { - $tableActions[] = [ - 'name' => 'forceDelete', - 'icon' => '$delete', - 'can' => 'forceDelete', - // 'color' => 'red darken-2', - 'color' => 'red', - ]; - } - - // show action - if ($this->getIndexOption('show')) { - $tableActions[] = [ - 'name' => 'Show', - 'icon' => 'mdi-eye', - 'color' => 'info', - 'show' => true, - 'title' => 'Show Item', - 'widthType' => '', - 'except' => [ - 'actions', - 'last_activities', - 'activities', - 'activities_show', - 'lastActivities_show', - ], - 'fullscreen' => true, - ]; - } - - // activity action - if ($this->getIndexOption('activity')) { - $tableActions[] = [ - 'name' => 'Last Operations', - 'icon' => 'mdi-book-open-variant', - 'color' => 'grey-darken-2', - 'show' => 'last_activities', - // 'conditions' => [ - // ['last_activities', '>', 0], - // ], - 'title' => 'Last Operations', - 'only' => [ - 'created_at' => 'Time', - 'event' => 'Event', - 'causer_id' => 'Causer ID', - 'causer_type' => 'Causer Type', - 'causer.name' => 'User Name', - // 'causer' => 'Causer', - 'properties.attributes' => 'New Data', - 'properties.old' => 'Previous Data', - ], - // 'except' => [ - // 'batch_uuid', - // ] - ]; + // admin.system.system_payment.payment routeName + // admin.crm.template/system/system-payments/pay/{price} + ...(method_exists($model, 'getTableRowPropsForPayment') + ? $model->getTableRowPropsForPayment() : []), + + ]; + // dd($actions); + } + + // duplicate action + if ($this->getIndexOption('duplicate')) { + $tableActions[] = [ + 'name' => 'duplicate', + // 'icon' => '$edit', + 'color' => 'primary darken-2', + ]; + } + + // edit action + if ($this->getIndexOption('edit')) { + $tableActions[] = [ + 'name' => 'edit', + 'color' => 'primary darken-2', + 'can' => $this->module->generatePermissionMiddlewareDefinition(Permission::EDIT->value, $this->routeName), + // 'color' => 'green darken-2', + ]; + } + + // delete action + if ($this->getIndexOption('delete')) { + $tableActions[] = [ + 'name' => 'delete', + 'can' => $this->module->generatePermissionMiddlewareDefinition(Permission::DELETE->value, $this->routeName), + 'variant' => 'outlined', + // 'color' => 'red darken-2', + 'color' => 'error', + ]; + } + + // restore action + if ($this->getIndexOption('restore')) { + $tableActions[] = [ + 'name' => 'restore', + // 'icon' => '$', + 'can' => 'restore', + // 'color' => 'red darken-2', + 'color' => 'green', + ]; + } + + // force delete action + if ($this->getIndexOption('forceDelete')) { + $tableActions[] = [ + 'name' => 'forceDelete', + 'icon' => '$delete', + 'can' => 'forceDelete', + // 'color' => 'red darken-2', + 'color' => 'red', + ]; + } + + // show action + if ($this->getIndexOption('show')) { + $tableActions[] = [ + 'name' => 'Show', + 'icon' => 'mdi-eye', + 'color' => 'info', + 'show' => true, + 'title' => 'Show Item', + 'widthType' => '', + 'except' => [ + 'actions', + 'last_activities', + 'activities', + 'activities_show', + 'lastActivities_show', + ], + 'fullscreen' => true, + ]; + } + + // activity action + if ($this->getIndexOption('activity')) { + $tableActions[] = [ + 'name' => 'Last Operations', + 'icon' => 'mdi-book-open-variant', + 'color' => 'grey-darken-2', + 'show' => 'last_activities', + // 'conditions' => [ + // ['last_activities', '>', 0], + // ], + 'title' => 'Last Operations', + 'only' => [ + 'created_at' => 'Time', + 'event' => 'Event', + 'causer_id' => 'Causer ID', + 'causer_type' => 'Causer Type', + 'causer.name' => 'User Name', + // 'causer' => 'Causer', + 'properties.attributes' => 'New Data', + 'properties.old' => 'Previous Data', + ], + // 'except' => [ + // 'batch_uuid', + // ] + ]; + } } - } - - $tableNavigationActions = Modularity::find($this->moduleName)->getNavigationActions($this->routeName); - $tableActionsCollection = collect($tableActions); - - foreach ($tableNavigationActions as $key => $navigationAction) { - $mergeable = isset($navigationAction['merge']) ? (bool) $navigationAction['merge'] : false; - - if (isset($navigationAction['name']) && $mergeable) { - $isFound = false; - $tableActionsCollection = $tableActionsCollection->reduce(function ($acc, $action) use ($navigationAction, &$isFound) { - if ($action['name'] == $navigationAction['name']) { - $acc->push(array_merge($action, $navigationAction)); - $isFound = true; - } else { - $acc->push($action); + + $tableNavigationActions = Modularity::find($this->moduleName)->getNavigationActions($this->routeName); + $tableActionsCollection = collect($tableActions); + + foreach ($tableNavigationActions as $key => $navigationAction) { + $mergeable = isset($navigationAction['merge']) ? (bool) $navigationAction['merge'] : false; + + if (isset($navigationAction['name']) && $mergeable) { + $isFound = false; + $tableActionsCollection = $tableActionsCollection->reduce(function ($acc, $action) use ($navigationAction, &$isFound) { + if ($action['name'] == $navigationAction['name']) { + $acc->push(array_merge($action, $navigationAction)); + $isFound = true; + } else { + $acc->push($action); + } + + return $acc; + }, collect([])); + + if ($isFound) { + unset($tableNavigationActions[$key]); } - - return $acc; - }, collect([])); - - if ($isFound) { - unset($tableNavigationActions[$key]); } } - } - - $tableActions = $tableActionsCollection->toArray(); - - // navigation actions - $tableActions = array_merge( - $tableActions, - $tableNavigationActions - ); - - $tableActions = Collection::make($tableActions)->reduce(function ($acc, $action, $key) { - $noSuperAdmin = $action['noSuperAdmin'] ?? false; - - // $action['is'] = true; - // if(isset($action['connector'])){ - // $connector = new Connector($action['connector']); - - // $connector->run($action, 'is'); - // } - // if(!$action['is']){ - // return $acc; - // } - - $isAllowed = $this->isAllowedItem( - $action, - searchKey: 'allowedRoles', - orClosure: fn ($item) => ! $noSuperAdmin && $this->user->isSuperAdmin(), + + $tableActions = $tableActionsCollection->toArray(); + + // navigation actions + $tableActions = array_merge( + $tableActions, + $tableNavigationActions ); - - if (! $isAllowed) { - return $acc; - } - - if (isset($action['formDraft'])) { - $formDraft = $action['formDraft']; - if ($formDraft === 'company') { - $action['form']['attributes'] = array_merge($action['form']['attributes'] ?? [], [ - 'modelValue' => $this->user->company, - ]); - } - $action['form']['attributes']['schema'] = $this->createFormSchema(getFormDraft($formDraft)); - } - - if (isset($action['href'])) { - $action['href'] = resolve_route($action['href']); - } - - if (isset($action['endpoint'])) { - $action['endpoint'] = resolve_route($action['endpoint']); - } - - if (isset($action['url'])) { - $action['url'] = resolve_route($action['url']); - } - - if (isset($action['form']) && isset($action['form']['attributes']) && isset($action['form']['attributes']['actionUrl'])) { - $action['form']['attributes']['actionUrl'] = resolve_route($action['form']['attributes']['actionUrl']); - } - - if (isset($action['responsive'])) { - $action = $this->applyResponsiveClasses( - item: $action, - searchKey: 'responsive', - display: 'flex', - classNotation: 'class' + + $tableActions = Collection::make($tableActions)->reduce(function ($acc, $action, $key) { + $noSuperAdmin = $action['noSuperAdmin'] ?? false; + + // $action['is'] = true; + // if(isset($action['connector'])){ + // $connector = new Connector($action['connector']); + + // $connector->run($action, 'is'); + // } + // if(!$action['is']){ + // return $acc; + // } + + $isAllowed = $this->isAllowedItem( + $action, + searchKey: 'allowedRoles', + orClosure: fn ($item) => ! $noSuperAdmin && $this->user->isSuperAdmin(), ); + + if (! $isAllowed) { + return $acc; + } + + if (isset($action['formDraft'])) { + $formDraft = $action['formDraft']; + if ($formDraft === 'company') { + $action['form']['attributes'] = array_merge($action['form']['attributes'] ?? [], [ + 'modelValue' => $this->user->company, + ]); + } + $action['form']['attributes']['schema'] = $this->createFormSchema(getFormDraft($formDraft)); + } + + if (isset($action['href'])) { + $action['href'] = resolve_route($action['href']); + } + + if (isset($action['endpoint'])) { + $action['endpoint'] = resolve_route($action['endpoint']); + } + + if (isset($action['url'])) { + $action['url'] = resolve_route($action['url']); + } + + if (isset($action['form']) && isset($action['form']['attributes']) && isset($action['form']['attributes']['actionUrl'])) { + $action['form']['attributes']['actionUrl'] = resolve_route($action['form']['attributes']['actionUrl']); + } + + if (isset($action['responsive'])) { + $action = $this->applyResponsiveClasses( + item: $action, + searchKey: 'responsive', + display: 'flex', + classNotation: 'class' + ); + } + + $acc[] = $action; + + return $acc; + }, []); + + // dropdown actions + if (count($tableActions) > 3) { + $this->tableAttributes['rowActionsType'] = 'dropdown'; } - - $acc[] = $action; - - return $acc; - }, []); - - // dropdown actions - if (count($tableActions) > 3) { - $this->tableAttributes['rowActionsType'] = 'dropdown'; } return $tableActions; diff --git a/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php b/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php index 8033d700d..2113a0b93 100644 --- a/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php +++ b/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php @@ -83,6 +83,7 @@ protected function oauthGoogleButtonSlot(string $type = 'sign-in'): array 'class' => 'mt-5 mb-2 custom-auth-button', 'color' => 'grey-lighten-1', 'density' => 'default', + 'block' => true, ], 'slots' => [ 'prepend' => [ @@ -115,6 +116,7 @@ protected function createAccountButtonSlot(): array 'class' => 'my-2 custom-auth-button', 'color' => 'grey-lighten-1', 'density' => 'default', + 'block' => true, ], ]; } @@ -325,6 +327,24 @@ protected function resolveFormSlotsPreset(?string $preset): array route('admin.password.reset.link') ), ], + 'login_mfa_options' => [ + 'options' => $this->authFormOptionSlot( + __('authentication.create-an-account'), + route(Route::hasAdmin('register.email_form')) + ), + ], + 'login_2fa_options' => [ + 'options' => $this->authFormOptionSlot( + __('authentication.back-to-login'), + route(Route::hasAdmin('login.form')) + ), + ], + 'step_up_options' => [ + 'options' => $this->authFormOptionSlot( + __('Resend verification code'), + route(Route::hasAdmin('step-up.resend')) + ), + ], 'have_account' => $this->haveAccountOptionSlot(), 'restart' => $this->restartOptionSlot(), 'resend' => $this->resendOptionSlot(), @@ -391,6 +411,11 @@ protected function resolveSlotsPreset(?string $preset): array $this->createAccountButtonSlot(), ]), ], + 'login_mfa_bottom' => [ + 'bottom' => $this->authBottomSlots([ + $this->oauthGoogleButtonSlot('sign-in'), + ]), + ], 'register_bottom' => [ 'bottom' => $this->authBottomSlots([ $this->oauthGoogleButtonSlot('sign-up'), diff --git a/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php b/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php index e51727d0c..5144150a9 100644 --- a/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php +++ b/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php @@ -167,7 +167,7 @@ public function setUserRegister(array $credentials) $user->setRememberToken(Str::random(60)); - $user->assignRole('client-manager'); + $user->assignRole(modularityConfig('default_register_role')); return $user; } diff --git a/src/Http/Controllers/Traits/Utilities/EnforcesMfaSetupOnLogin.php b/src/Http/Controllers/Traits/Utilities/EnforcesMfaSetupOnLogin.php new file mode 100644 index 000000000..13fe580d5 --- /dev/null +++ b/src/Http/Controllers/Traits/Utilities/EnforcesMfaSetupOnLogin.php @@ -0,0 +1,45 @@ +bound(SecurityService::class) + ? app()->make(SecurityService::class) + : null; + + if ( + $securityService === null + || ! modularityConfig('security.enabled', false) + || ! modularityConfig('security.mfa.enabled', false) + || ! modularityConfig('security.mfa.strict', true) + || ! $securityService->userRequiresMfa($user) + || $securityService->userHasEnabledMfa($user) + ) { + return null; + } + + $this->guard()->logout(); + + $message = __('MFA setup is required for your role.'); + + return $request->wantsJson() + ? new JsonResponse([ + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 403) + : redirect()->to(route(Route::hasAdmin('login.form')))->withErrors([ + 'mfa' => $message, + ]); + } +} diff --git a/src/Http/Controllers/Traits/Utilities/FormPageUtility.php b/src/Http/Controllers/Traits/Utilities/FormPageUtility.php index 25a6370de..f62185ef4 100644 --- a/src/Http/Controllers/Traits/Utilities/FormPageUtility.php +++ b/src/Http/Controllers/Traits/Utilities/FormPageUtility.php @@ -10,10 +10,11 @@ trait FormPageUtility * @param int|null $id * @return Model */ - public function getRepositoryItem($id = null, $withoutDefaultScopes = false) + public function getRepositoryItem(&$id = null, $withoutDefaultScopes = false) { if ($this->isSingleton) { $item = $this->repository->getModel()->single(); + $id = $item->id; } elseif ($id) { // Generate scopes for authorization $scopes = $withoutDefaultScopes ? [] : $this->filterScope($this->nestedParentScopes()); diff --git a/src/Http/Controllers/Traits/Utilities/HandlesMfaAuthentication.php b/src/Http/Controllers/Traits/Utilities/HandlesMfaAuthentication.php new file mode 100644 index 000000000..6c21ed61b --- /dev/null +++ b/src/Http/Controllers/Traits/Utilities/HandlesMfaAuthentication.php @@ -0,0 +1,342 @@ +isMfaEnabled() + && (bool) modularityConfig('security.mfa.remove_password_login', true); + } + + protected function isMfaEnabled(): bool + { + return (bool) modularityConfig('security.mfa.enabled', false); + } + + protected function mfaProvider(): string + { + return (string) modularityConfig('security.mfa.provider', 'email_otp'); + } + + protected function mfaSessionKey(): string + { + return (string) modularityConfig('security.mfa.session_key', '2fa:user:id'); + } + + protected function mfaFlowSessionKey(): string + { + return (string) modularityConfig('security.mfa.flow_session_key', '2fa:flow:key'); + } + + protected function mfaOtpField(): string + { + return (string) modularityConfig('security.mfa.otp_field', 'verify-code'); + } + + protected function mfaChallengePageKey(): string + { + return (string) modularityConfig('security.mfa.challenge_page', 'login_2fa'); + } + + protected function mfaLoginPageKey(): string + { + return (string) modularityConfig('security.mfa.login_page', 'login_mfa'); + } + + protected function mfaChallengeFormRoute(): string + { + return (string) modularityConfig('security.mfa.challenge_form_route', Route::hasAdmin('login-2fa.form')); + } + + protected function mfaRegistrationSuccessRoute(): string + { + return (string) modularityConfig('security.mfa.registration_success_route', Route::hasAdmin('register.verification.success')); + } + + protected function mfaCodeLength(): int + { + return (int) modularityConfig('security.mfa.email_otp.length', 6); + } + + protected function mfaCodeExpiryMinutes(): int + { + return (int) modularityConfig('security.mfa.email_otp.expire_minutes', 10); + } + + protected function mfaCodeMaxAttempts(): int + { + return (int) modularityConfig('security.mfa.email_otp.max_attempts', 5); + } + + protected function mfaCachePrefix(): string + { + return (string) modularityConfig('security.mfa.email_otp.cache_prefix', 'mfa:email-otp'); + } + + protected function mfaAllowsRegistrationFromLogin(): bool + { + return (bool) modularityConfig('security.mfa.register_first_time', true); + } + + protected function usesEmailOtpMfaProvider(): bool + { + return $this->mfaProvider() === 'email_otp'; + } + + protected function userHasMfaEnabled($user): bool + { + return ! empty($user?->google_2fa_secret) && (bool) ($user?->google_2fa_enabled ?? false); + } + + protected function resolveChallengeRouteName(): string + { + $challengeRoute = $this->mfaChallengeFormRoute(); + + if (! Route::has($challengeRoute)) { + return Route::hasAdmin('login.form'); + } + + return $challengeRoute; + } + + protected function resolveRegistrationSuccessRouteName(): string + { + $routeName = $this->mfaRegistrationSuccessRoute(); + + if (! Route::has($routeName)) { + return Route::hasAdmin('register.verification.success'); + } + + return $routeName; + } + + protected function generateMfaCode(): string + { + $length = max(4, min(10, $this->mfaCodeLength())); + $max = (10 ** $length) - 1; + + return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + } + + protected function createEmailOtpChallenge(Request $request, User $user): string + { + $flowKey = $this->mfaCachePrefix() . ':' . (string) Str::uuid(); + $code = $this->generateMfaCode(); + $expiresAt = now()->addMinutes($this->mfaCodeExpiryMinutes()); + + Cache::put($flowKey, [ + 'user_id' => $user->id, + 'email' => $user->email, + 'code_hash' => Hash::make($code), + 'attempts' => 0, + 'expires_at' => $expiresAt->toDateTimeString(), + ], $expiresAt); + + $request->session()->put($this->mfaFlowSessionKey(), $flowKey); + $request->session()->put($this->mfaSessionKey(), $user->id); + + $user->notify(new LoginMfaCodeNotification( + code: $code, + expiresAt: $expiresAt, + )); + + return $flowKey; + } + + protected function registrationFromMfaLoginResponse(Request $request, string $email): JsonResponse|RedirectResponse + { + if (! $this->mfaAllowsRegistrationFromLogin()) { + return $this->mfaLoginFailedResponse($request, __('auth.failed')); + } + + $response = Register::broker('register_verified_users')->sendVerificationLink( + ['email' => $email], + function ($notifiable, $token) { + $notifiable->sendRegisterNotification($token); + } + ); + + if ($response !== RegisterBroker::VERIFICATION_LINK_SENT) { + return $this->mfaLoginFailedResponse($request, __($response)); + } + + $redirectRoute = $this->resolveRegistrationSuccessRouteName(); + + if ($request->wantsJson()) { + return new JsonResponse([ + 'message' => __('authentication.pre-register-description'), + 'variant' => MessageStage::SUCCESS, + 'redirector' => route($redirectRoute), + ], 200); + } + + return redirect()->route($redirectRoute)->with('status', __('authentication.pre-register-description')); + } + + protected function handleMfaLoginRequest(Request $request): JsonResponse|RedirectResponse + { + $request->validate(['email' => 'required|email']); + + $user = User::where('email', $request->string('email')->toString())->first(); + + if (! $user) { + return $this->registrationFromMfaLoginResponse($request, $request->string('email')->toString()); + } + + return $this->startMfaChallenge($request, $user) + ?? $this->mfaLoginFailedResponse($request, __('auth.failed')); + } + + protected function mfaLoginFailedResponse(Request $request, string $message): JsonResponse|RedirectResponse + { + if ($request->wantsJson()) { + return new JsonResponse([ + 'errors' => [ + 'email' => [$message], + ], + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 422); + } + + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => $message]); + } + + protected function startMfaChallenge(Request $request, $user): JsonResponse|RedirectResponse|null + { + if (! $this->isMfaEnabled()) { + return null; + } + + if ($this->usesEmailOtpMfaProvider()) { + $this->createEmailOtpChallenge($request, $user); + } elseif ($this->userHasMfaEnabled($user)) { + $this->guard()->logout(); + $request->session()->put($this->mfaSessionKey(), $user->id); + } else { + return null; + } + + $challengeRoute = $this->resolveChallengeRouteName(); + + $redirectUrl = $this->redirector->to(route($challengeRoute))->getTargetUrl(); + + return $request->wantsJson() + ? new JsonResponse(['redirector' => $redirectUrl], 200) + : $this->redirector->to($redirectUrl); + } + + protected function resolveMfaUserFromSession(Request $request): ?User + { + $userId = $request->session()->get($this->mfaSessionKey()); + + if (! $userId) { + return null; + } + + return User::find($userId); + } + + protected function clearMfaSession(Request $request): void + { + $flowKey = (string) $request->session()->get($this->mfaFlowSessionKey(), ''); + if ($flowKey !== '') { + Cache::forget($flowKey); + } + + $request->session()->forget($this->mfaSessionKey()); + $request->session()->forget($this->mfaFlowSessionKey()); + } + + protected function validateMfaOtp(User $user, Request $request): bool + { + if ($this->usesEmailOtpMfaProvider()) { + $flowKey = (string) $request->session()->get($this->mfaFlowSessionKey(), ''); + $challenge = $flowKey !== '' ? Cache::get($flowKey) : null; + + if (! is_array($challenge)) { + return false; + } + + if ((int) ($challenge['user_id'] ?? 0) !== (int) $user->id) { + return false; + } + + if ((int) ($challenge['attempts'] ?? 0) >= $this->mfaCodeMaxAttempts()) { + Cache::forget($flowKey); + + return false; + } + + $otp = (string) $request->input($this->mfaOtpField(), ''); + $valid = Hash::check($otp, (string) ($challenge['code_hash'] ?? '')); + + if (! $valid) { + $challenge['attempts'] = (int) ($challenge['attempts'] ?? 0) + 1; + $expiresAt = now()->addMinutes($this->mfaCodeExpiryMinutes()); + Cache::put($flowKey, $challenge, $expiresAt); + } + + return $valid; + } + + $otp = (string) $request->input($this->mfaOtpField(), ''); + + return (new Google2FA)->verifyKey((string) $user->google_2fa_secret, $otp); + } + + protected function mfaFailureResponse(Request $request, string $message): JsonResponse|RedirectResponse + { + $challengeRoute = $this->resolveChallengeRouteName(); + + if ($request->wantsJson()) { + return new JsonResponse([ + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 422); + } + + return $this->redirector->to(route($challengeRoute))->withErrors([ + 'error' => $message, + ]); + } + + protected function completeMfaLogin(Request $request, User $user): JsonResponse|RedirectResponse + { + $this->authManager->guard(Modularity::getAuthGuardName())->loginUsingId($user->id); + $this->clearMfaSession($request); + + $redirectUrl = redirect()->intended($this->redirectTo)->getTargetUrl(); + + if ($request->wantsJson()) { + return new JsonResponse([ + 'message' => __('authentication.login-success-message'), + 'variant' => MessageStage::SUCCESS, + 'redirector' => $redirectUrl, + ], 200); + } + + return $this->redirector->intended($this->redirectTo); + } +} diff --git a/src/Http/Controllers/ChatController.php b/src/Http/Controllers/Utility/ChatController.php similarity index 96% rename from src/Http/Controllers/ChatController.php rename to src/Http/Controllers/Utility/ChatController.php index b656148cf..37a5f2668 100644 --- a/src/Http/Controllers/ChatController.php +++ b/src/Http/Controllers/Utility/ChatController.php @@ -1,10 +1,10 @@ validate([ + 'module' => 'required|string', + 'route' => 'required|string', + 'source' => 'required|string', + 'locale' => 'nullable|string|max:32', + 'locale_scoped' => 'sometimes|boolean', + 'exclude_id' => 'nullable|integer', + ]); + + try { + $result = $service->proposeUniqueSlug( + $validated['module'], + $validated['route'], + $validated['source'], + $validated['locale'] ?? null, + $validated['locale_scoped'] ?? true, + $validated['exclude_id'] ?? null, + ); + + return response()->json($result); + } catch (InvalidArgumentException $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 422); + } + } +} diff --git a/src/Http/Controllers/Utility/SlugInputValidationController.php b/src/Http/Controllers/Utility/SlugInputValidationController.php new file mode 100644 index 000000000..f28a70f50 --- /dev/null +++ b/src/Http/Controllers/Utility/SlugInputValidationController.php @@ -0,0 +1,46 @@ +validate([ + 'module' => 'required|string', + 'route' => 'required|string', + 'value' => 'nullable|string', + 'locale' => 'nullable|string|max:32', + 'locale_scoped' => 'sometimes|boolean', + 'exclude_id' => 'nullable|integer', + ]); + + try { + $result = $service->validate( + $validated['module'], + $validated['route'], + $validated['value'] ?? '', + $validated['locale'] ?? null, + $validated['locale_scoped'] ?? true, + $validated['exclude_id'] ?? null, + ); + + return response()->json($result); + } catch (InvalidArgumentException $e) { + return response()->json([ + 'valid' => false, + 'message' => $e->getMessage(), + 'normalized' => '', + ], 422); + } + } +} diff --git a/src/Http/Controllers/TagController.php b/src/Http/Controllers/Utility/TagController.php similarity index 96% rename from src/Http/Controllers/TagController.php rename to src/Http/Controllers/Utility/TagController.php index cef3a05af..ceedffbe3 100644 --- a/src/Http/Controllers/TagController.php +++ b/src/Http/Controllers/Utility/TagController.php @@ -1,6 +1,6 @@ expectsJson()) { - $modularityAdminRouteNamePrefix = Modularity::getAdminRouteNamePrefix(); - // Define auth routes that should not be stored as intended URL - $excludedRoutes = Arr::map([ - 'login.form', 'login', 'logout', - 'register.form', 'register', 'register.success', - 'password.reset.link', 'password.reset.email', - 'password.reset.success', 'password.reset', - 'password.reset.update', - 'impersonate.stop', 'impersonate', - ], function ($route) use ($modularityAdminRouteNamePrefix) { - return $modularityAdminRouteNamePrefix ? $modularityAdminRouteNamePrefix . '.' . $route : $route; - }); - - // Only store the previous URL if it's not an auth route - if (! in_array($request->route()->getName(), $excludedRoutes)) { - session()->put('url.intended', url()->previous()); - } - } - if ($request->expectsJson()) { $referer = $request->headers->get('referer'); session()->put('url.intended', $referer); - return response()->json([ - 'message' => 'Unauthenticated.', - 'mode' => 'experimental', - // 'redirector' => $referer, - ], 401); + return null; + } + + $modularityAdminRouteNamePrefix = Modularity::getAdminRouteNamePrefix(); + // Define auth routes that should not be stored as intended URL + $excludedRoutes = Arr::map([ + 'login.form', 'login', 'logout', + 'register.form', 'register', 'register.success', + 'password.reset.link', 'password.reset.email', + 'password.reset.success', 'password.reset', + 'password.reset.update', + 'impersonate.stop', 'impersonate', + ], function ($route) use ($modularityAdminRouteNamePrefix) { + return $modularityAdminRouteNamePrefix ? $modularityAdminRouteNamePrefix . '.' . $route : $route; + }); + + $routeName = $request->route()?->getName(); + // Only store the previous URL if it's not an auth route + if ($routeName === null || ! in_array($routeName, $excludedRoutes)) { + session()->put('url.intended', url()->previous()); } return route(Route::hasAdmin('login.form')); - // return $request->expectsJson() ? null : route('login.create'); } } diff --git a/src/Http/Middleware/Concerns/HandlesUnauthenticatedInertiaAndAjax.php b/src/Http/Middleware/Concerns/HandlesUnauthenticatedInertiaAndAjax.php new file mode 100644 index 000000000..5c6bdf888 --- /dev/null +++ b/src/Http/Middleware/Concerns/HandlesUnauthenticatedInertiaAndAjax.php @@ -0,0 +1,89 @@ +redirectTo() ?? $this->fallbackLoginUrlForUnauthenticated(); + + if ($request->header('X-Inertia')) { + return response('', 409)->withHeaders([ + 'X-Inertia-Location' => $loginUrl, + ]); + } + + if ($request->ajax()) { + return response()->json([ + 'message' => $e->getMessage(), + 'login_url' => $loginUrl ?: null, + 'redirect' => (bool) $loginUrl, + ], 401); + } + + throw $e; + } + + // Pipeline bazen AuthenticationException'ı render edip RedirectResponse(302) olarak döndürür. + // Bu durumu Inertia/AJAX istekleri için de dönüştür. + if ($response instanceof RedirectResponse && $this->isLoginRedirectResponse($response)) { + if ($request->header('X-Inertia')) { + return response('', 409)->withHeaders([ + 'X-Inertia-Location' => $response->getTargetUrl(), + ]); + } + + if ($request->ajax()) { + return response()->json([ + 'message' => __('Unauthenticated.'), + 'login_url' => $response->getTargetUrl(), + 'redirect' => true, + ], 401); + } + } + + return $response; + } + + /** + * Redirect response'un login sayfasına yönlendirip yönlendirmediğini kontrol eder. + * /login, /admin/login, /system/login gibi "login" ile biten path'leri yakalar. + */ + protected function isLoginRedirectResponse(RedirectResponse $response): bool + { + if (! in_array($response->getStatusCode(), [301, 302, 303], true)) { + return false; + } + + $path = trim(parse_url($response->getTargetUrl(), PHP_URL_PATH) ?? '', '/'); + + return str_ends_with($path, 'login'); + } + + protected function fallbackLoginUrlForUnauthenticated(): string + { + return route('login'); + } +} diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php index ed6a15254..ee909b6ef 100644 --- a/src/Http/Middleware/HandleInertiaRequests.php +++ b/src/Http/Middleware/HandleInertiaRequests.php @@ -7,6 +7,7 @@ use Inertia\Middleware; use Unusualify\Modularity\Entities\File; use Unusualify\Modularity\Entities\Media; +use Unusualify\Modularity\Support\ModularityFlashWarnings; class HandleInertiaRequests extends Middleware { @@ -44,6 +45,8 @@ public function share(Request $request): array 'message' => fn () => $request->session()->get('message'), 'success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error'), + /** Stacked non-blocking warnings (session {@see ModularityFlashWarnings::SESSION_KEY}). */ + 'warnings' => fn () => $request->session()->pull(ModularityFlashWarnings::SESSION_KEY, []), ], 'config' => [ diff --git a/src/Http/Middleware/RequireMfaMiddleware.php b/src/Http/Middleware/RequireMfaMiddleware.php new file mode 100644 index 000000000..70230bc2d --- /dev/null +++ b/src/Http/Middleware/RequireMfaMiddleware.php @@ -0,0 +1,48 @@ +user(); + + if (! $this->securityService->userRequiresMfa($user)) { + return $next($request); + } + + if ($this->securityService->userHasEnabledMfa($user)) { + return $next($request); + } + + if ($request->expectsJson()) { + return response()->json([ + 'message' => 'MFA is required for this role.', + ], 403); + } + + if (Route::has('admin.login.form')) { + auth()->logout(); + + return redirect()->route('admin.login.form')->withErrors([ + 'mfa' => 'MFA setup is required before continuing.', + ]); + } + + abort(403, 'MFA is required for this role.'); + } +} diff --git a/src/Http/Middleware/SessionSecurityMiddleware.php b/src/Http/Middleware/SessionSecurityMiddleware.php new file mode 100644 index 000000000..8d645013b --- /dev/null +++ b/src/Http/Middleware/SessionSecurityMiddleware.php @@ -0,0 +1,42 @@ +session()->get('security_last_seen_at', time()); + + if ((time() - $lastSeenAt) > ($idleTimeoutMinutes * 60)) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + if ($request->expectsJson()) { + return response()->json(['message' => 'Session timed out. Please login again.'], 401); + } + + return redirect()->route('admin.login.form')->withErrors([ + 'session' => 'Session timed out. Please login again.', + ]); + } + + $request->session()->put('security_last_seen_at', time()); + + return $next($request); + } +} diff --git a/src/Http/Middleware/StepUpMiddleware.php b/src/Http/Middleware/StepUpMiddleware.php new file mode 100644 index 000000000..dbd3d8332 --- /dev/null +++ b/src/Http/Middleware/StepUpMiddleware.php @@ -0,0 +1,45 @@ +user(); + $currentRouteName = $request->route()?->getName(); + + if (! $user || ! is_string($currentRouteName) || $currentRouteName === '') { + return $next($request); + } + + $matchedCapability = $this->securityService->matchedUserStepUpCapability($user, $currentRouteName, $capability); + + if (! $matchedCapability) { + return $next($request); + } + + $verifiedAt = (int) $request->session()->get('security_step_up_verified_at', 0); + $ttlMinutes = (int) modularityConfig('security.session.step_up_ttl_minutes', 15); + + if ($verifiedAt > 0 && (time() - $verifiedAt) <= ($ttlMinutes * 60)) { + return $next($request); + } + + return $this->stepUpService->interrupt($request, $matchedCapability); + } +} diff --git a/src/Hydrates/Inputs/InputHydrate.php b/src/Hydrates/Inputs/InputHydrate.php index 609035cf7..b9811265b 100644 --- a/src/Hydrates/Inputs/InputHydrate.php +++ b/src/Hydrates/Inputs/InputHydrate.php @@ -19,7 +19,8 @@ * Output types: input-assignment, input-browser, input-chat, input-checklist, input-checklist-group, * input-comparison-table, input-date, input-file, input-filepond, input-filepond-avatar, input-form-tabs, * input-image, input-payment-service, input-price, input-process, input-radio-group, input-repeater, - * input-select-scroll, input-spread, input-tag, input-tagger. Also: select, group (JsonHydrate). + * input-select-scroll, input-spread, input-tag, input-tagger. Also: select, group (JsonHydrate), + * module-route-model (ModuleRouteModelHydrate → select of module routes / model FQCNs). */ abstract class InputHydrate { @@ -183,7 +184,7 @@ final public function render(): array $this->input = $this->hydrateRules(); - $this->input = Arr::except($this->input, ['route', 'model', 'repository', 'cascades', 'connector']); + $this->input = Arr::except($this->input, ['route', 'model', 'repository', 'cascades', 'connector', 'onlyParentSegmentModels']); return $this->input; } @@ -486,4 +487,20 @@ public function __toString() { return $this->render(); } + + /** + * Add translated props to input + * + * @param array &$input + * @param array|string $props comma separated props or array of props + * @return void + */ + public function addTranslatedProps(&$input, array|string $props) + { + $props = is_string($props) ? explode(',', $props) : $props; + $input['translatedProps'] = array_unique(array_merge([ + ...($input['translatedProps'] ?? []), + ...$props, + ])); + } } diff --git a/src/Hydrates/Inputs/ModuleRouteModelHydrate.php b/src/Hydrates/Inputs/ModuleRouteModelHydrate.php new file mode 100644 index 000000000..24ff4b6d4 --- /dev/null +++ b/src/Hydrates/Inputs/ModuleRouteModelHydrate.php @@ -0,0 +1,40 @@ + true to list only models using {@see \Modules\Cms\Entities\Concerns\HasParentSegment}. + */ +class ModuleRouteModelHydrate extends SelectHydrate +{ + public $selectable = true; + + /** + * @var array + */ + public $requirements = [ + 'itemValue' => 'value', + 'itemTitle' => 'title', + 'default' => null, + 'cascadeKey' => 'items', + 'returnObject' => false, + ]; + + public function hydrate() + { + $this->input['type'] = 'select'; + $this->input['itemValue'] = 'value'; + $this->input['itemTitle'] = 'title'; + + $onlyParentSegmentModels = (bool) ($this->input['onlyParentSegmentModels'] ?? false); + $this->input['items'] = Modularity::getModuleRouteModelSelectItems($onlyParentSegmentModels); + + return parent::hydrate(); + } +} diff --git a/src/Hydrates/Inputs/OtpInputHydrate.php b/src/Hydrates/Inputs/OtpInputHydrate.php new file mode 100644 index 000000000..fdc6d6e16 --- /dev/null +++ b/src/Hydrates/Inputs/OtpInputHydrate.php @@ -0,0 +1,32 @@ + 6, + ]; + + /** + * Manipulate Input Schema Structure + * + * @return void + */ + public function hydrate() + { + $input = $this->input; + + $input['type'] = 'otp-input'; + + // add your logic + + return $input; + } +} diff --git a/src/Hydrates/Inputs/RevisionHydrate.php b/src/Hydrates/Inputs/RevisionHydrate.php new file mode 100644 index 000000000..81cd9a3a4 --- /dev/null +++ b/src/Hydrates/Inputs/RevisionHydrate.php @@ -0,0 +1,84 @@ + 'revisionable_id', + 'noSubmit' => true, + 'col' => ['cols' => 12], + 'default' => null, + /** Max height of the scrollable revision list (CSS length, e.g. 320px). */ + 'maxHeight' => '320px', + 'isSecondary' => true, + ]; + + /** + * Manipulate Input Schema Structure + * + * @return void + */ + public function hydrate() + { + $input = $this->input; + + $input['type'] = 'input-revision'; + + $snakeRouteName = snakeCase($this->routeName); + + $input['restoreEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'restoreRevision', + [$snakeRouteName => ':id'] + ); + + $input['approveEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'approveRevision', + [$snakeRouteName => ':id'] + ); + + $input['rejectEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'rejectRevision', + [$snakeRouteName => ':id'] + ); + + $input['showEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'showView', + [$snakeRouteName => ':id'] + ); + + $input['fetchEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'listRevisions', + [$snakeRouteName => ':id'] + ); + + $canApprove = false; + $canReject = false; + $canRestore = false; + + if($this->module && $this->module->getRepository($this->routeName)->hasBehavior('revisions')) { + $canApprove = $this->module->getModel($this->routeName)->usesRevisionWorkflow() && $this->module->allowedPermission(Permission::REVISION_APPROVE->value, $this->routeName); + $canReject = $this->module->getModel($this->routeName)->usesRevisionWorkflow() && $this->module->allowedPermission(Permission::REVISION_REJECT->value, $this->routeName); + $canRestore = $this->module->allowedPermission(Permission::REVISION_RESTORE->value, $this->routeName); + } + + $input['canApprove'] = $canApprove; + $input['canReject'] = $canReject; + $input['canRestore'] = $canRestore; + + return $input; + } +} diff --git a/src/Hydrates/Inputs/SlugHydrate.php b/src/Hydrates/Inputs/SlugHydrate.php new file mode 100644 index 000000000..3ace221e6 --- /dev/null +++ b/src/Hydrates/Inputs/SlugHydrate.php @@ -0,0 +1,137 @@ + 'Slug', + 'default' => '', + 'localeScoped' => true, + 'excludeId' => null, + 'locale' => null, + /** When true (default), the slug input exposes an active toggle and submits `{ slug, active }` per locale. */ + 'manageActive' => true, + /** + * Optional. Mirrored title/source text for one-click slug generation (see {@see \SlugInputGenerateController}). + * Often populated via form {@code set:} events from the title field. + */ + 'slugSourceValue' => null, + ]; + + /** + * Manipulate Input Schema Structure + */ + public function hydrate(): array + { + $input = $this->input; + + $input['type'] = 'input-slug'; + + if (isset($input['_moduleName']) && isset($input['_routeName'])) { + $input['endpoint'] = resolve_route(Route::hasAdmin('inputs.slug.validate')); + if (Route::hasAdmin('inputs.slug.generate')) { + $input['generateEndpoint'] = resolve_route(Route::hasAdmin('inputs.slug.generate')); + } + $input = $this->appendParentSegmentPrefixSchema($input); + } + + if (modularityConfig('cms_routing.admin.slug_public_path_preview', true)) { + $input['cmsPublicPathPreview'] = [ + 'prefix' => trim((string) modularityConfig('cms_routing.front_route_prefix', 'cms'), '/'), + 'default_locale' => (string) modularityConfig('cms_routing.default_locale', config('app.locale')), + 'hide_default_locale' => (bool) modularityConfig('cms_routing.hide_default_locale_segment', false), + ]; + } + + $this->addTranslatedProps($input, 'slugSourceValue'); + + $input['rules'] ??= 'required'; + + return $input; + } + + /** + * When the submodule repository uses {@see \Modules\Cms\Repositories\Traits\ParentSegmentTrait} + * (via {@see \Modules\Cms\Repositories\Traits\CMRTrait}), or the route model uses + * {@see HasParentSegment} / {@see \Unusualify\Modularity\Entities\Traits\IsCmr}, pass locale → normalized prefix map for the slug field prefix. + * + * @param array $input + * @return array + */ + protected function appendParentSegmentPrefixSchema(array $input): array + { + if (! class_exists(\Modules\Cms\Services\CmsParentSegmentResolver::class)) { + return $input; + } + + $module = Modularity::find($input['_moduleName']); + if ($module === null) { + return $input; + } + + $routeName = $input['_routeName']; + $modelFqcn = $this->resolveRouteModelFqcn($module, $routeName); + if ($modelFqcn === null) { + return $input; + } + + if (! $this->routeUsesParentSegmentFeatures($module, $routeName, $modelFqcn)) { + return $input; + } + + $resolver = App::make(\Modules\Cms\Services\CmsParentSegmentResolver::class); + if (! $resolver->enabled() || ! $resolver->tablesReady()) { + return $input; + } + + $input['parentSegmentPrefixByLocale'] = $resolver->normalizedPrefixesMapForTargetClass($modelFqcn); + + return $input; + } + + /** + * @param object $module \Unusualify\Modularity\Module + */ + protected function resolveRouteModelFqcn($module, string $routeName): ?string + { + try { + $fqcn = $module->getModel($routeName, false); + } catch (\Throwable) { + return null; + } + + return is_string($fqcn) && class_exists($fqcn) ? $fqcn : null; + } + + /** + * @param object $module \Unusualify\Modularity\Module + */ + protected function routeUsesParentSegmentFeatures($module, string $routeName, string $modelFqcn): bool + { + try { + $repoClass = $module->getRepository($routeName, false); + } catch (\Throwable) { + $repoClass = null; + } + + + if (is_string($repoClass) && $repoClass !== '' && class_exists($repoClass)) { + $repo = App::make($repoClass); + if (method_exists($repo, 'usesParentSegmentForUrl')) { + return $repo->usesParentSegmentForUrl(); + } + } + + return classHasTrait($modelFqcn, \Modules\Cms\Entities\Concerns\HasParentSegment::class); + } +} diff --git a/src/Modularity.php b/src/Modularity.php index 5e6015dcf..3db8e0adb 100755 --- a/src/Modularity.php +++ b/src/Modularity.php @@ -10,6 +10,7 @@ use Nwidart\Modules\FileRepository; use Nwidart\Modules\Json; use Unusualify\Modularity\Contracts\CurrencyProviderInterface; +use Modules\Cms\Entities\Concerns\HasParentSegment; use Unusualify\Modularity\Exceptions\ModularitySystemPathException; class Modularity extends FileRepository @@ -745,4 +746,69 @@ public function getCurrencyForLanguageBasedPrices() return false; } + + /** + * Options for admin selects: each enabled module route that exposes an Eloquent model. + * Value is the model FQCN; title is "{moduleName} - {routeName}" (display only). + * + * When {@code $onlyParentSegmentModels} is true, only routes whose model uses {@see HasParentSegment} are listed. + * + * @return list + */ + public function getModuleRouteModelSelectItems(bool $onlyParentSegmentModels = false): array + { + $out = []; + foreach ($this->all() as $module) { + $moduleName = $module->getName(); + foreach ($module->getRawRouteConfigs(null, true) as $routeConfig) { + if (empty($routeConfig['name'])) { + continue; + } + $routeName = $routeConfig['name']; + try { + $fqcn = $module->getModel($routeName, false); + } catch (\Throwable) { + continue; + } + if (! is_string($fqcn) || ! class_exists($fqcn)) { + continue; + } + if ($onlyParentSegmentModels && ! classHasTrait($fqcn, HasParentSegment::class)) { + continue; + } + $out[] = [ + 'value' => $fqcn, + 'title' => $moduleName . ' - ' . $routeName, + ]; + } + } + + usort($out, fn ($a, $b) => strcmp($a['title'], $b['title'])); + + return $out; + } + + /** + * Resolve "ModuleName::RouteName" for a module route model class, if registered in any module config. + */ + public function resolveTargetModuleRouteForModelClass(string $modelClass): ?string + { + foreach ($this->all() as $module) { + foreach ($module->getRawRouteConfigs(null, true) as $routeConfig) { + if (empty($routeConfig['name'])) { + continue; + } + try { + $fqcn = $module->getModel($routeConfig['name'], false); + } catch (\Throwable) { + continue; + } + if ($fqcn === $modelClass) { + return $module->getName() . '::' . $routeConfig['name']; + } + } + } + + return null; + } } diff --git a/src/Module.php b/src/Module.php index be4895d4d..4d66810dd 100755 --- a/src/Module.php +++ b/src/Module.php @@ -12,6 +12,8 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Schema; @@ -19,6 +21,7 @@ use Nwidart\Modules\Laravel\Module as NwidartModule; use Nwidart\Modules\Support\Config\GenerateConfigReader; use Unusualify\Modularity\Activators\ModuleActivator; +use Unusualify\Modularity\Entities\Enums\Permission; use Unusualify\Modularity\Exceptions\ModularityException; use Unusualify\Modularity\Facades\Modularity; use Unusualify\Modularity\Repositories\Repository; @@ -54,6 +57,11 @@ class Module extends NwidartModule 'tagsUpdate', 'assignments', 'createAssignment', + 'restoreRevision', + 'approveRevision', + 'rejectRevision', + 'showView', + 'listRevisions', ]; /** @@ -245,7 +253,7 @@ protected function fireModuleEvent($event, $route): void /** * Determine whether the current module route activated. */ - public function isEnabledRoute($route): bool + public function isEnabledRoute(string $route): bool { return $this->moduleActivator->hasStatus($route, true); } @@ -656,6 +664,60 @@ public function routeHasTable($routeName = null, $notation = null): bool return Schema::hasTable($tableName); } + /** + * Generate permission name from permission and route name + * + * @param mixed $permission + * @param mixed $routeName + */ + public function generatePermissionName(string $permission, string $routeName): string + { + return Permission::generatePermissionName($permission, $routeName); + } + + /** + * Generate permission middleware definition + * + * @param mixed $permission + * @param mixed $routeName + */ + public function generatePermissionMiddlewareDefinition(string $permission, string $routeName): string + { + return Permission::generatePermissionMiddlewareDefinition($permission, $routeName); + } + + /** + * Check if the user has the permission + * + * @param mixed $permissionName + * @param mixed $routeName + */ + public function userHasPermission(string $permissionName, string $routeName): bool + { + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + if (! $user) { + return false; + } + + $permissionName = $this->generatePermissionName($permissionName, $routeName); + + return $user->hasPermission($permissionName); + } + + /** + * Check if the user is allowed to perform the permission + * + * @param mixed $permission + * @param mixed $routeName + */ + public function allowedPermission(string $permission, string $routeName) + { + $permissionName = $this->generatePermissionName($permission, $routeName); + + return Gate::allows($permissionName); + } + /** * getConfigPath */ diff --git a/src/Notifications/LoginMfaCodeNotification.php b/src/Notifications/LoginMfaCodeNotification.php new file mode 100644 index 000000000..a2ee49ddb --- /dev/null +++ b/src/Notifications/LoginMfaCodeNotification.php @@ -0,0 +1,36 @@ +subject(Lang::get('Login Verification Code')) + ->line(Lang::get('Use this code to complete your login: :code', ['code' => $this->code])) + ->line(Lang::get('This code will expire at :time', ['time' => $this->expiresAt->format('H:i')])) + ->line(Lang::get('If you did not attempt to login, you can ignore this email.')); + } +} + diff --git a/src/Notifications/StepUpCodeNotification.php b/src/Notifications/StepUpCodeNotification.php new file mode 100644 index 000000000..07e0718fa --- /dev/null +++ b/src/Notifications/StepUpCodeNotification.php @@ -0,0 +1,36 @@ +subject(Lang::get('Security Verification Code')) + ->line(Lang::get('Use this code to confirm your sensitive action: :code', ['code' => $this->code])) + ->line(Lang::get('This code will expire at :time', ['time' => $this->expiresAt->format('H:i')])) + ->line(Lang::get('If you did not trigger this action, you can ignore this email.')); + } +} diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index 95cc67a4b..23fc8138e 100755 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -47,7 +47,7 @@ public function boot() Gate::define('dashboard', function ($user) { return $this->authorize($user, function ($user) { - return $this->userHasPermission($user, ['dashboard']); + return $user->hasPermission('dashboard'); // return $this->userHasRole($user, [UserRole::VIEWONLY, UserRole::PUBLISHER, UserRole::ADMIN]); }); }); @@ -55,90 +55,13 @@ public function boot() foreach (Permission::all() as $permission) { Gate::define($permission->name, function ($user) use ($permission) { return $this->authorize($user, function ($user) use ($permission) { - return $this->userHasPermission($user, [$permission->name]); + // return $this->userHasPermission($user, [$permission->name]); + return $user->hasPermission($permission->name); // return $this->userHasRole($user, [UserRole::VIEWONLY, UserRole::PUBLISHER, UserRole::ADMIN]); }); }); } - // Gate::define('press-release_access', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasPermission($user, ['press-release_access']); - // // return $this->userHasRole($user, [UserRole::VIEWONLY, UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('list', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::VIEWONLY, UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('edit', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('reorder', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('publish', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('feature', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('delete', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('duplicate', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('upload', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::PUBLISHER, UserRole::ADMIN]); - // }); - // }); - - // Gate::define('manage-users', function ($user) { - // return $this->authorize($user, function ($user) { - // return $this->userHasRole($user, [UserRole::ADMIN]); - // }); - // }); - - // // As an admin, I can edit users, except superadmins - // // As a non-admin, I can edit myself only - // Gate::define('edit-user', function ($user, $editedUser = null) { - // return $this->authorize($user, function ($user) use ($editedUser) { - // $editedUserObject = User::find($editedUser); - // return ($this->userHasRole($user, [UserRole::ADMIN]) || $user->id == $editedUser) - // && ($editedUserObject ? $editedUserObject->role !== self::SUPERADMIN : true); - // }); - // }); - - // Gate::define('publish-user', function ($user) { - // return $this->authorize($user, function ($user) { - // $editedUserObject = User::find(request('id')); - // return $this->userHasRole($user, [UserRole::ADMIN]) && ($editedUserObject ? $user->id !== $editedUserObject->id && $editedUserObject->role !== self::SUPERADMIN : false); - // }); - // }); - Gate::define('impersonate', function ($user) { return $user->role === self::SUPERADMIN; }); diff --git a/src/Providers/BaseServiceProvider.php b/src/Providers/BaseServiceProvider.php index 82b55379d..396ca22cf 100755 --- a/src/Providers/BaseServiceProvider.php +++ b/src/Providers/BaseServiceProvider.php @@ -29,6 +29,8 @@ use Unusualify\Modularity\Http\ViewComposers\Urls; use Unusualify\Modularity\Logging\ModularityLogHandler; use Unusualify\Modularity\Modularity; +use Unusualify\Modularity\Services\BulkCsv\BulkCsvImportOrchestrator; +use Unusualify\Modularity\Services\BulkCsv\BulkImportService; use Unusualify\Modularity\Services\CacheRelationshipGraph; use Unusualify\Modularity\Services\Currency\NullCurrencyProvider; use Unusualify\Modularity\Services\Currency\SystemPricingCurrencyProvider; @@ -44,6 +46,7 @@ use Unusualify\Modularity\Support\HostRouteRegistrar; use Unusualify\Modularity\Support\HostRouting; use Unusualify\Modularity\Translation\Translator; +use Unusualify\Modularity\Validation\Validator as ModularityValidator; class BaseServiceProvider extends ServiceProvider { @@ -219,6 +222,10 @@ public function register() return new MigrationBackup; }); + $this->app->singleton(BulkCsvImportOrchestrator::class); + + $this->app->singleton(BulkImportService::class); + $this->app->singleton('modularity.redirect', function (Application $app) { return new RedirectService; }); @@ -241,6 +248,8 @@ public function register() $this->app->register(TelescopeServiceProvider::class); $this->registerTranslationService(); + + $this->registerValidationFactoryResolver(); } /** @@ -349,6 +358,18 @@ public function registerTranslationService() }); } + /** + * Use a Validator that normalizes {placeholder} lines to :placeholder before Laravel replaces values. + */ + public function registerValidationFactoryResolver(): void + { + $this->callAfterResolving('validator', function ($factory) { + $factory->resolver(function ($translator, $data, $rules, $messages, $attributes) { + return new ModularityValidator($translator, $data, $rules, $messages, $attributes); + }); + }); + } + /** * {@inheritdoc} */ diff --git a/src/Providers/ModularityProvider.php b/src/Providers/ModularityProvider.php index 0634cb392..9c45f8321 100755 --- a/src/Providers/ModularityProvider.php +++ b/src/Providers/ModularityProvider.php @@ -15,6 +15,7 @@ class ModularityProvider extends ServiceProvider // Unusual Providers BaseServiceProvider::class, ModuleServiceProvider::class, + SecurityServiceProvider::class, RouteServiceProvider::class, AuthServiceProvider::class, CoverageServiceProvider::class, diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php index 9130a193a..dcd2a7271 100755 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Providers/RouteServiceProvider.php @@ -8,10 +8,12 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; use Nwidart\Modules\Support\Config\GenerateConfigReader; +use Unusualify\Modularity\Contracts\CanBulkSheet; use Unusualify\Modularity\Facades\HostRoutingRegistrar; use Unusualify\Modularity\Facades\Modularity; use Unusualify\Modularity\Facades\ModularityRoutes; use Unusualify\Modularity\Http\Controllers\GlideController; +use Unusualify\Modularity\Module; class RouteServiceProvider extends ServiceProvider { @@ -353,7 +355,7 @@ protected function bootMacros() Route::macro('additionalRoutes', function ($url, $routeName, $options) { - $customRoutes = $defaults = [ + $defaults = [ 'reorder', // 'publish', // 'bulkPublish', @@ -361,7 +363,11 @@ protected function bootMacros() // 'feature', // 'preview', // 'bulkFeature', - // 'restoreRevision', + 'showView', + 'listRevisions', + 'restoreRevision', + 'approveRevision', + 'rejectRevision', 'restore', 'bulkRestore', @@ -377,31 +383,92 @@ protected function bootMacros() 'createAssignment', ]; - $controllerClass = "{$routeName}Controller"; + $customRoutes = $defaults; + + $groupStack = Route::getGroupStack(); + $namespace = $groupStack[count($groupStack) - 1]['namespace'] ?? null; + $controllerClass = null; + if ($namespace && class_exists("{$namespace}\\{$routeName}Controller")) { + $controllerClass = app()->make("{$namespace}\\{$routeName}Controller"); + } + + $bulkSheetStepUpMiddleware = null; + if ($controllerClass instanceof CanBulkSheet) { + try { + if ($controllerClass instanceof CanBulkSheet) { + $customRoutes = array_merge($customRoutes, [ + 'bulkSheetTool', + 'bulkSheetDryRun', + 'bulkSheetCommit', + 'bulkSheetExport', + ]); + $ability = $controllerClass->bulkSheetStepUpAbility(); + if ( + $ability !== null && $ability !== '' + && modularityConfig('cms_features.register_middlewares', true) + && modularityConfig('security.enabled', false) + ) { + $bulkSheetStepUpMiddleware = 'modularity.security.step_up:' . $ability; + } + } + } catch (\Throwable) { + // Submodule may omit this controller; skip bulk sheet routes. + } + } + + $controllerName = "{$routeName}Controller"; $snakeCase = snakeCase($routeName); - // if (isset($options['only'])) { - // $customRoutes = array_intersect( - // $defaults, - // (array) $options['only'] - // ); - // } elseif (isset($options['except'])) { - // $customRoutes = array_diff( - // $defaults, - // (array) $options['except'] - // ); - // } - foreach ($customRoutes as $customRoute) { - $customRouteKebab = kebabCase($customRoute); - $routeSlug = "{$url}/{$customRouteKebab}"; + foreach ($customRoutes as $customRoute) { $mapping = [ // 'as' => $customRoutePrefix . ".{$customRoute}", 'as' => $options['as'] . ".{$customRoute}", - 'uses' => "{$controllerClass}@{$customRoute}", + 'uses' => "{$controllerName}@{$customRoute}", ]; - if ($customRoute === 'assignments') { - Route::get("{$url}/{{$snakeCase}}/assignments", $mapping); + if ($controllerClass instanceof CanBulkSheet && in_array($customRoute, [ + 'bulkSheetTool', + 'bulkSheetDryRun', + 'bulkSheetCommit', + 'bulkSheetExport', + ], true)) { + $names = $controllerClass->bulkSheetWebRouteNames(); + $asKey = match ($customRoute) { + 'bulkSheetTool' => $names['tool'], + 'bulkSheetDryRun' => $names['dryRun'], + 'bulkSheetCommit' => $names['commit'], + 'bulkSheetExport' => $names['export'], + default => $customRoute, + }; + $mapping['as'] = $options['as'] . '.' . $asKey; + + if ($customRoute === 'bulkSheetTool') { + Route::get("{$url}/bulk", $mapping); + } elseif ($customRoute === 'bulkSheetDryRun') { + $r = Route::post("{$url}/bulk/dry-run", $mapping); + if ($bulkSheetStepUpMiddleware !== null) { + $r->middleware($bulkSheetStepUpMiddleware); + } + } elseif ($customRoute === 'bulkSheetCommit') { + $r = Route::post("{$url}/bulk/commit", $mapping); + if ($bulkSheetStepUpMiddleware !== null) { + $r->middleware($bulkSheetStepUpMiddleware); + } + } elseif ($customRoute === 'bulkSheetExport') { + Route::get("{$url}/bulk/export", $mapping); + } + } + + if (! $controllerClass || ! method_exists($controllerClass, $customRoute)) { + continue; + } + + $customRouteKebab = kebabCase($customRoute); + $routeSlug = "{$url}/{$customRouteKebab}"; + + if (in_array($customRoute, ['assignments', 'listRevisions'])) { + // dd($customRoute, $routeSlug, $mapping, $url, $snakeCase); + Route::get("{$url}/{{$snakeCase}}/{$customRouteKebab}", $mapping); } if ($customRoute === 'createAssignment') { @@ -429,11 +496,7 @@ protected function bootMacros() Route::put($routeSlug, $mapping); } - if (in_array($customRoute, ['duplicate'])) { - Route::put($routeSlug . "/{{$snakeCase}}", $mapping); - } - - if (in_array($customRoute, ['preview'])) { + if (in_array($customRoute, ['duplicate', 'preview', 'showView', 'restoreRevision', 'approveRevision', 'rejectRevision'])) { Route::put($routeSlug . "/{{$snakeCase}}", $mapping); } @@ -447,7 +510,6 @@ protected function bootMacros() 'bulkForceDelete', ]) ) { - Route::post($routeSlug, $mapping); } diff --git a/src/Providers/SecurityServiceProvider.php b/src/Providers/SecurityServiceProvider.php new file mode 100644 index 000000000..b2d9c3d9a --- /dev/null +++ b/src/Providers/SecurityServiceProvider.php @@ -0,0 +1,36 @@ +app->singleton(SecurityService::class, fn () => new SecurityService); + + if (! modularityConfig('security.enabled', false)) { + return; + } + + ModularityRoutes::addDefaultMiddlewares([ + 'modularity.security.session', + 'modularity.security.require_mfa', + 'modularity.security.step_up', + ]); + } + + public function boot(): void + { + Route::aliasMiddleware('modularity.security.session', SessionSecurityMiddleware::class); + Route::aliasMiddleware('modularity.security.require_mfa', RequireMfaMiddleware::class); + Route::aliasMiddleware('modularity.security.step_up', StepUpMiddleware::class); + } +} diff --git a/src/Repositories/Logic/MethodTransformers.php b/src/Repositories/Logic/MethodTransformers.php index 34595ef61..cc27758b6 100644 --- a/src/Repositories/Logic/MethodTransformers.php +++ b/src/Repositories/Logic/MethodTransformers.php @@ -213,10 +213,31 @@ public function beforeSave($object, $fields) public function afterSave($object, $fields) { foreach ($this->traitsMethods(__FUNCTION__) as $method) { + if ($this->shouldBypassAfterSaveHook($method)) { + continue; + } + $this->$method($object, $fields); } } + /** + * When RevisionsTrait::bypassAfterSaves sets passAfterSave* (after opt-in via each trait’s + * pendingBypassRevision*), that hook is skipped. Naming: afterSaveFooTrait → passAfterSaveFooTrait. + * + * @see traitsMethods() + */ + protected function shouldBypassAfterSaveHook(string $method): bool + { + if (! str_starts_with($method, 'afterSave')) { + return false; + } + + $passProperty = preg_replace('/^after/', 'passAfter', $method); + + return property_exists($this, $passProperty) && $this->{$passProperty} === true; + } + /** * @param Model $object * @return void diff --git a/src/Repositories/Logic/Relationships.php b/src/Repositories/Logic/Relationships.php index 79fdcbe6f..cd5662ef4 100755 --- a/src/Repositories/Logic/Relationships.php +++ b/src/Repositories/Logic/Relationships.php @@ -17,6 +17,12 @@ trait Relationships use CheckSnapshot, ResolveConnector; + /** + * When true, {@see \Unusualify\Modularity\Repositories\Traits\RevisionsTrait::bypassAfterSaves} may set + * `passAfterSaveRelationships` during pending-only revision saves so {@see afterSaveRelationships} is skipped. + */ + protected bool $pendingBypassRevisionRelationships = true; + public $exceptRelations = []; /** diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index b6e1a7df9..7cb9227b9 100755 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -15,11 +15,13 @@ use Unusualify\Modularity\Contracts\ModuleableInterface; use Unusualify\Modularity\Models\Model; use Unusualify\Modularity\Repositories\Contracts\Repository as RepositoryContract; +use Unusualify\Modularity\Repositories\Traits\Concerns\InteractsWithAttachmentPayloads; use Unusualify\Modularity\Traits\ManageNames; abstract class Repository implements CacheableInterface, ModuleableInterface, RepositoryContract, UserAwareCacheInterface { - use ManageNames, + use InteractsWithAttachmentPayloads, + ManageNames, Logic\InspectTraits, Logic\RelationshipHelpers, Logic\MethodTransformers, diff --git a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php new file mode 100644 index 000000000..e9c7d67c7 --- /dev/null +++ b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php @@ -0,0 +1,343 @@ + + */ + protected function resolveAttachmentRoles(string $traitFqcn, string $chunkInputTypeRegex, array $fields, callable $inferRoleFromKeyValue): array + { + $fromTrait = $this->getColumns($traitFqcn); + + $fromChunk = collect($this->chunkInputs(all: true)) + ->filter(fn ($input) => isset($input['type']) && preg_match($chunkInputTypeRegex, $input['type'])) + ->pluck('name') + ->all(); + + $fromFields = []; + foreach ($fields as $key => $value) { + if ($this->reservedAttachmentFieldKey($key)) { + continue; + } + + if (in_array($key, getLocales(), true) && is_array($value)) { + foreach ($value as $subKey => $subVal) { + if ($subKey === 'active' || $this->reservedAttachmentFieldKey((string) $subKey)) { + continue; + } + if ($inferRoleFromKeyValue((string) $subKey, $subVal)) { + $fromFields[] = (string) $subKey; + } + } + + continue; + } + + if ($inferRoleFromKeyValue($key, $value)) { + $fromFields[] = $key; + } + } + + return collect($fromTrait) + ->merge($fromChunk) + ->merge($fromFields) + ->unique() + ->values() + ->all(); + } + + protected function reservedAttachmentFieldKey(string $key): bool + { + return in_array($key, [ + 'translations', + 'translationLanguages', + '_token', + '_method', + 'revisionId', + 'activeLanguage', + 'preview', + ], true); + } + + /** + * Role appears in the incoming payload (top-level or under a locale bucket). + */ + protected function attachmentRoleIsPresentInFields(array $fields, string $role): bool + { + if (array_key_exists($role, $fields)) { + return true; + } + + foreach (getLocales() as $locale) { + if (array_key_exists($role, $fields[$locale] ?? [])) { + return true; + } + } + + return false; + } + + /** + * @return array|list|null + */ + protected function getAttachmentPayloadForRole(array $fields, string $role): mixed + { + if (array_key_exists($role, $fields)) { + return $fields[$role]; + } + + $nested = []; + foreach (getLocales() as $locale) { + if (array_key_exists($role, $fields[$locale] ?? [])) { + $nested[$locale] = $fields[$locale][$role]; + } + } + + return $nested === [] ? null : $nested; + } + + /** + * Payload uses locale keys (translated image/file field). + */ + protected function isLocaleKeyedAttachmentPayload(mixed $payload): bool + { + if (! is_array($payload)) { + return false; + } + + foreach (array_keys($payload) as $key) { + if (in_array($key, getLocales(), true)) { + return true; + } + } + + return false; + } + + protected function isAttachmentRoleTranslatedInSchema(string $role): bool + { + $chunked = $this->chunkInputs(all: true); + + return (bool) ($chunked[$role]['translated'] ?? false); + } + + /** + * Translated vs locale-keyed payload when schema is missing (e.g. revision JSON only). + * + * @param array $fields + */ + protected function isAttachmentRoleTranslatedForFields(array $fields, string $role): bool + { + if ($this->isAttachmentRoleTranslatedInSchema($role)) { + return true; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if (! is_array($payload)) { + return false; + } + + return $this->isLocaleKeyedAttachmentPayload($payload); + } + + /** + * File / image field payload: either locale => rows or a list of rows with id. + */ + protected function valueLooksLikeMorphAttachmentPayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + return true; + } + + foreach ($value as $item) { + if (is_array($item) && isset($item['id'])) { + return true; + } + } + + return false; + } + + /** + * Media library row (image) — distinguish from file-library rows which also use numeric id. + */ + protected function arrayLooksLikeMediaLibraryItem(array $item): bool + { + if (isset($item['thumbnail']) || isset($item['medium'])) { + return true; + } + + $meta = $item['metadatas'] ?? null; + if (is_array($meta)) { + $def = $meta['default'] ?? null; + if (is_array($def) && (array_key_exists('altText', $def) || array_key_exists('video', $def))) { + return true; + } + } + + return false; + } + + /** + * Image / media-library payload for inferring roles from raw request data. + */ + protected function valueLooksLikeImageRolePayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + foreach (getLocales() as $loc) { + if (! array_key_exists($loc, $value)) { + continue; + } + $slice = $value[$loc]; + if ($slice === null || ! is_array($slice)) { + continue; + } + foreach ($slice as $row) { + if (is_array($row) && $this->arrayLooksLikeMediaLibraryItem($row)) { + return true; + } + } + } + + return false; + } + + foreach ($value as $item) { + if (is_array($item) && $this->arrayLooksLikeMediaLibraryItem($item)) { + return true; + } + } + + return false; + } + + /** + * File-library payload (excludes media-library / image rows). + */ + protected function valueLooksLikeFileRolePayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + foreach (getLocales() as $loc) { + if (! array_key_exists($loc, $value)) { + continue; + } + $slice = $value[$loc]; + if ($slice === null || $slice === []) { + return true; + } + if (! is_array($slice)) { + continue; + } + foreach ($slice as $row) { + if (is_array($row) && isset($row['id']) && ! $this->arrayLooksLikeMediaLibraryItem($row)) { + return true; + } + } + } + + return false; + } + + foreach ($value as $item) { + if (! is_array($item) || ! isset($item['id'])) { + continue; + } + if ($this->arrayLooksLikeMediaLibraryItem($item)) { + continue; + } + + return true; + } + + return false; + } + + /** + * FilePond field payload: list of rows with a `uuid` (temp folder or persisted row id path). + */ + protected function valueLooksLikeFilepondRolePayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + foreach (getLocales() as $loc) { + if (! array_key_exists($loc, $value)) { + continue; + } + $slice = $value[$loc]; + if ($slice === null || $slice === [] || ! is_array($slice)) { + continue; + } + foreach ($slice as $row) { + if (is_array($row) && isset($row['uuid'])) { + return true; + } + } + } + + return false; + } + + foreach ($value as $item) { + if (is_array($item) && isset($item['uuid'])) { + return true; + } + } + + return false; + } + + /** + * Chunked input is a media-library image field (not file-library). + */ + protected function isImageLibraryInputRole(string $role): bool + { + $input = $this->chunkInputs(all: true)[$role] ?? null; + if (! is_array($input) || ! isset($input['type'])) { + return false; + } + + return preg_match('/\bimage\b/i', (string) $input['type']) === 1; + } + + /** + * Exclude from {@see \Unusualify\Modularity\Repositories\Traits\FilesTrait} so media IDs are not written as {@code file_id}. + * + * @param array $fields + */ + protected function shouldExcludeRoleFromFileTrait(string $role, array $fields): bool + { + if ($this->isImageLibraryInputRole($role)) { + return true; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if (! is_array($payload)) { + return false; + } + + return $this->valueLooksLikeImageRolePayload($payload); + } +} diff --git a/src/Repositories/Traits/FilepondsTrait.php b/src/Repositories/Traits/FilepondsTrait.php index fd2a23ee9..52919c348 100644 --- a/src/Repositories/Traits/FilepondsTrait.php +++ b/src/Repositories/Traits/FilepondsTrait.php @@ -3,10 +3,22 @@ namespace Unusualify\Modularity\Repositories\Traits; use Illuminate\Support\Arr; -use Unusualify\Modularity\Facades\Filepond; +use Illuminate\Support\Collection; +use Unusualify\Modularity\Entities\Filepond as FilepondEntity; +use Unusualify\Modularity\Entities\Model; +use Unusualify\Modularity\Entities\TemporaryFilepond; +use Unusualify\Modularity\Facades\Filepond as FilepondFacade; trait FilepondsTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveFilepondsTrait` during + * pending-only revision saves so {@see afterSaveFilepondsTrait} is skipped (live Filepond persistence deferred). + * + * Set to false on the repository to run {@see afterSaveFilepondsTrait} even while queuing a pending revision. + */ + protected bool $pendingBypassRevisionFilepondsTrait = true; + public function setColumnsFilepondsTrait($columns, $inputs) { $traitName = get_class_short_name(__TRAIT__); @@ -24,6 +36,173 @@ public function setColumnsFilepondsTrait($columns, $inputs) return $columns; } + /** + * Preview: merge persisted `fileponds` with payload (revision or form) so FilePond fields reflect pending data. + * Rows whose UUID still exists only in {@see TemporaryFilepond} (pending approval / bypassed afterSave) are + * surfaced as unsaved {@see FilepondEntity} models with `isTemporaryRevisionPreview` set. + * + * @param Model $object + * @param array $fields + * @return Model + */ + public function hydrateFilepondsTrait($object, $fields) + { + if ($this->shouldIgnoreFieldBeforeSave('fileponds')) { + return $object; + } + if (! $object->has('fileponds')) { + return $object; + } + + $object->setRelation('fileponds', $this->getPreviewFileponds($object, $fields)); + + return $object; + } + + /** + * @param Model $object + * @param array $fields + */ + private function getPreviewFileponds($object, array $fields): Collection + { + $object->loadMissing('fileponds'); + + $original = $object->fileponds; + $out = Collection::make(); + $replacedRoles = []; + + + foreach ($this->getColumns(__TRAIT__) as $column) { + if (! $this->dataHasFilepondPayloadKey($fields, $column)) { + continue; + } + + $files = data_get($fields, $column); + + if (preg_match('/\.\*\./', $column)) { + foreach ($files as $index => $nestedFiles) { + $nestedRole = preg_replace('/\.\*\./', ".$index.", $column); + $replacedRoles[$nestedRole] = true; + + if (Arr::isAssoc($nestedFiles)) { + foreach ($nestedFiles as $locale => $nestedFilesByLocale) { + if (empty($nestedFilesByLocale)) { + continue; + } + $rows = is_array($nestedFilesByLocale) ? $nestedFilesByLocale : []; + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $nestedRole, (string) $locale, $original)); + } + } else { + if (empty($nestedFiles)) { + continue; + } + $rows = is_array($nestedFiles) ? $nestedFiles : []; + $locale = (string) config('app.locale', 'en'); + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $nestedRole, $locale, $original)); + } + } + } else { + $role = $column; + $replacedRoles[$role] = true; + + if (Arr::isAssoc($files)) { + foreach ($files as $locale => $filesByLocale) { + if (empty($filesByLocale)) { + continue; + } + $rows = is_array($filesByLocale) ? $filesByLocale : []; + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $role, (string) $locale, $original)); + } + } else { + $rows = is_array($files) ? $files : []; + $locale = (string) config('app.locale', 'en'); + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $role, $locale, $original)); + } + } + } + + foreach ($original as $filepond) { + if (isset($replacedRoles[$filepond->role])) { + continue; + } + $out->push($filepond); + } + + return $out->values(); + } + + /** + * @param array $rows + */ + private function mapFilepondRowsToPreviewModels(Model $object, array $rows, string $role, string $locale, Collection $original): Collection + { + $acc = Collection::make(); + + foreach ($rows as $item) { + if (! is_array($item) || empty($item['uuid'])) { + continue; + } + + $uuid = (string) $item['uuid']; + + $existing = $original->first( + fn (FilepondEntity $f) => $f->role === $role + && (string) $f->locale === $locale + && $f->uuid === $uuid + ); + + if ($existing) { + $existing->isTemporaryRevisionPreview = false; + $acc->push($existing); + + continue; + } + + $fileName = (string) ($item['file_name'] ?? ($item['file']['name'] ?? '')); + + $preview = new FilepondEntity([ + 'uuid' => $uuid, + 'file_name' => $fileName, + 'role' => $role, + 'locale' => $locale, + 'filepondable_id' => $object->getKey(), + 'filepondable_type' => $object->getMorphClass(), + ]); + $preview->exists = false; + $preview->isTemporaryRevisionPreview = $this->filepondUuidIsTemporaryForPreview($uuid, $object, $original); + $acc->push($preview); + } + + return $acc; + } + + /** + * True when the upload is still only in {@see TemporaryFilepond} (typical when revision workflow deferred afterSave). + */ + private function filepondUuidIsTemporaryForPreview(string $uuid, Model $object, Collection $originalFileponds): bool + { + $persistedOnSubject = $originalFileponds->contains( + fn (FilepondEntity $f) => $f->uuid === $uuid + && (int) $f->filepondable_id === (int) $object->getKey() + ); + + if ($persistedOnSubject) { + return false; + } + + return TemporaryFilepond::where('folder_name', $uuid)->exists(); + } + + /** + * Same presence rules as {@see afterSaveFilepondsTrait}: allow empty list (cleared field); skip only when absent. + * + * @param array $fields + */ + private function dataHasFilepondPayloadKey(array $fields, string $column): bool + { + return data_get($fields, $column) !== null; + } + public function afterSaveFilepondsTrait($object, $fields) { $columns = $this->getColumns(__TRAIT__); @@ -44,14 +223,14 @@ public function afterSaveFilepondsTrait($object, $fields) if (empty($nestedFilesByLocale)) { continue; } - Filepond::saveFile($object, $nestedFilesByLocale, $nestedRole, $locale); + FilepondFacade::saveFile($object, $nestedFilesByLocale, $nestedRole, $locale); $this->mustTouchEloquentModel(); } } else { if (empty($nestedFiles)) { continue; } - Filepond::saveFile($object, $nestedFiles, $nestedRole); + FilepondFacade::saveFile($object, $nestedFiles, $nestedRole); $this->mustTouchEloquentModel(); } } @@ -62,11 +241,11 @@ public function afterSaveFilepondsTrait($object, $fields) if (empty($filesByLocale)) { continue; } - Filepond::saveFile($object, $filesByLocale, $role, $locale); + FilepondFacade::saveFile($object, $filesByLocale, $role, $locale); $this->mustTouchEloquentModel(); } } else { - Filepond::saveFile($object, $files, $role); + FilepondFacade::saveFile($object, $files, $role); $this->mustTouchEloquentModel(); } } diff --git a/src/Repositories/Traits/FilesTrait.php b/src/Repositories/Traits/FilesTrait.php index ffb6d7a7b..857ed5b87 100755 --- a/src/Repositories/Traits/FilesTrait.php +++ b/src/Repositories/Traits/FilesTrait.php @@ -9,6 +9,12 @@ trait FilesTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveFilesTrait` during pending-only + * revision saves so {@see afterSaveFilesTrait} is skipped. + */ + protected bool $pendingBypassRevisionFilesTrait = true; + public function setColumnsFilesTrait($columns, $inputs) { $traitName = get_class_short_name(__TRAIT__); @@ -35,17 +41,7 @@ public function hydrateFilesTrait($object, $fields) return $object; } - $filesCollection = Collection::make(); - $filesFromFields = $this->getFiles($object, $fields); - - $filesFromFields->each(function ($file) use ($object, $filesCollection) { - $newFile = File::withTrashed()->find($file['file_id']); - $pivot = $newFile->newPivot($object, Arr::except($file, ['id']), 'fileables', true); - $newFile->setRelation('pivot', $pivot); - $filesCollection->push($newFile); - }); - - $object->setRelation('files', $filesCollection); + $object->setRelation('files', $this->getPreviewFiles($object, $fields)); return $object; } @@ -61,16 +57,76 @@ public function afterSaveFilesTrait($object, $fields) return; } - $this->getFiles($object, $fields)->each(function ($file) use ($object) { - if (isset($file['id']) && $file['id']) { - $result = $object->files()->updateExistingPivot($file['id'], Arr::except($file, ['id', 'file_id'])); - if ($result) { - $this->mustTouchEloquentModel(); + $object->loadMissing('files'); + + foreach ($this->resolveFileTraitRoles($fields) as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if ($payload === null) { + continue; + } + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + continue; + } + + $slice = $rolePayload[$locale]; + $this->detachFilesForRoleLocale($object, $role, $locale); + + if ($slice === null) { + continue; + } + + $rows = is_array($slice) ? $slice : []; + $this->attachFileSpecsFromRows($object, $rows, $role, $locale); } } else { - $object->files()->attach($file['file_id'], Arr::except($file, ['file_id'])); - $this->mustTouchEloquentModel(); + $locale = (string) config('app.locale', 'en'); + $this->detachFilesForRoleLocale($object, $role, $locale); + $rows = is_array($payload) ? $payload : []; + $this->attachFileSpecsFromRows($object, $rows, $role, $locale); } + } + } + + /** + * Remove all file pivots for this role + locale so the next attach matches {@code fields} exactly. + */ + private function detachFilesForRoleLocale($object, string $role, string $locale): void + { + $relatedKey = $object->files()->getRelated()->getQualifiedKeyName(); + $ids = $object->files() + ->wherePivot('role', $role) + ->wherePivot('locale', $locale) + ->pluck($relatedKey); + + if ($ids->isEmpty()) { + return; + } + + $object->files()->detach($ids->all()); + $this->mustTouchEloquentModel(); + } + + /** + * @param array $rows + */ + private function attachFileSpecsFromRows($object, array $rows, string $role, string $locale): void + { + $this->collectPivotSpecsForFileRows($object, $rows, $role, $locale)->each(function ($file) use ($object) { + if (! File::withTrashed()->whereKey($file['file_id'])->exists()) { + return; + } + + $object->files()->attach($file['file_id'], Arr::except($file, ['file_id', 'id'])); + $this->mustTouchEloquentModel(); }); } @@ -84,17 +140,6 @@ public function getFormFieldsFilesTrait($object, $fields, $schema) $fileInputs = $this->getColumns(__TRAIT__); if (! empty($fileInputs) && $object->has('files')) { $schema = $schema ?? $this->inputs(); - // foreach ($object->files->groupBy('pivot.role') as $role => $filesByRole) { - // foreach ($filesByRole->groupBy('pivot.locale') as $locale => $filesByLocale) { - // // $fields['files'][$locale][$role] = $filesByLocale->map(function ($file) { - // // return $file->mediableFormat(); - // // }); - // $fields[$role][$locale] = $filesByLocale->map(function ($file) { - // return $file->mediableFormat(); - // }); - // } d - // } - $systemLocales = getLocales(); $default_locale = config('app.locale'); $fallback_locale = config('app.fallback_locale'); $filesByRole = $object->files->groupBy('pivot.role'); @@ -124,10 +169,6 @@ public function getFormFieldsFilesTrait($object, $fields, $schema) })) : Collection::make([]), ]; } - - // foreach ($systemLocales as $locale) { - // $fields[$role][$locale] = []; - // } } } } @@ -136,66 +177,138 @@ public function getFormFieldsFilesTrait($object, $fields, $schema) } /** - * @param array $fields - * @return Collection + * Preview: merge DB file pivots with payload. + * + * @param array $fields */ - private function getFiles($object, $fields) + private function getPreviewFiles($object, array $fields): Collection { - $files = Collection::make(); - $systemLocales = getLocales(); - $fileRoles = $this->getColumns(__TRAIT__); - $fileablesTable = modularityConfig('tables.fileables', 'um_fileables'); + $object->loadMissing('files'); - foreach ($fileRoles as $role) { - if (isset($fields[$role]) && count(array_keys($fields[$role])) > 0) { - $default_locale = array_keys($fields[$role])[0]; - foreach ($systemLocales as $locale) { - if (isset($fields[$role][$locale])) { - Collection::make($fields[$role][$locale])->each(function ($file) use ($object, $fileablesTable, &$files, $role, $locale) { - $fileableId = $object->files() - ->select($fileablesTable . '.id as pivot_id') - ->where('file_id', $file['id']) - ->where('role', $role) - ->where('locale', $locale)->value('pivot_id') ?? null; - - $files->push([ - ...($fileableId ? ['id' => $fileableId] : []), - 'file_id' => $file['id'], - 'role' => $role, - 'locale' => $locale, - ]); - }); - } else { - Collection::make($fields[$role])->each(function ($file) use ($object, $fileablesTable, &$files, $role, $locale) { - $fileableId = $object->files() - ->select($fileablesTable . '.id as pivot_id') - ->where('file_id', $file['id']) - ->where('role', $role) - ->where('locale', $locale)->value('pivot_id') ?? null; - - $files->push([ - ...($fileableId ? ['id' => $fileableId] : []), - 'file_id' => $file['id'], - 'role' => $role, - 'locale' => $locale, - ]); - }); + $roles = $this->resolveFileTraitRoles($fields); + $original = $object->files; + + if (! collect($roles)->contains(fn ($role) => $this->attachmentRoleIsPresentInFields($fields, $role))) { + return $original; + } + + $out = Collection::make(); + + foreach ($roles as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + $out = $out->merge($original->where('pivot.role', $role)); + + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + $out = $out->merge($original->filter( + fn ($f) => $f->pivot->role === $role && $f->pivot->locale === $locale + )); + + continue; + } + + $slice = $rolePayload[$locale]; + if ($slice === null) { + continue; } + + $rows = is_array($slice) ? $slice : []; + $out = $out->merge($this->pivotSpecsToFileModels( + $object, + $this->collectPivotSpecsForFileRows($object, $rows, $role, $locale) + )); } - // foreach($fields[$role] as $locale => $filesForRole){ - // Collection::make($filesForRole)->each(function ($file) use (&$files, $role, $locale) { - // $files->push([ - // 'id' => $file['id'], - // 'role' => $role, - // 'locale' => $locale, - // ]); - // }); - // } } else { - // dd($role); + $rows = is_array($payload) ? $payload : []; + $locale = (string) config('app.locale', 'en'); + $out = $out->merge($this->pivotSpecsToFileModels( + $object, + $this->collectPivotSpecsForFileRows($object, $rows, $role, $locale) + )); } } - return $files; + $out = $out->merge($original->filter( + fn ($f) => ! in_array($f->pivot->role, $roles, true) + )); + + return $out->values(); + } + + /** + * Roles for file pivots only — never image / media-library fields (e.g. {@code photos}). + * + * @param array $fields + * @return list + */ + private function resolveFileTraitRoles(array $fields): array + { + $resolved = $this->resolveAttachmentRoles( + __TRAIT__, + '/\bfile\b/', + $fields, + fn ($k, $v) => $this->valueLooksLikeFileRolePayload($v) + ); + + return array_values(array_filter( + $resolved, + fn (string $role) => ! $this->shouldExcludeRoleFromFileTrait($role, $fields) + )); + } + + /** + * @param array $rows + */ + private function collectPivotSpecsForFileRows($object, array $rows, string $role, string $locale): Collection + { + $specs = Collection::make(); + $fileablesTable = modularityConfig('tables.fileables', 'um_fileables'); + + Collection::make($rows)->each(function ($file) use ($object, $fileablesTable, $specs, $role, $locale) { + if (! is_array($file) || ! isset($file['id'])) { + return; + } + + $fileableId = $object->files() + ->select($fileablesTable . '.id as pivot_id') + ->where('file_id', $file['id']) + ->where('role', $role) + ->where('locale', $locale)->value('pivot_id') ?? null; + + $specs->push([ + ...($fileableId ? ['id' => $fileableId] : []), + 'file_id' => $file['id'], + 'role' => $role, + 'locale' => $locale, + ]); + }); + + return $specs; + } + + private function pivotSpecsToFileModels($object, Collection $specs): Collection + { + $filesCollection = Collection::make(); + + $specs->each(function ($file) use ($object, $filesCollection) { + $newFile = File::withTrashed()->find($file['file_id']); + if (! $newFile) { + return; + } + + $pivot = $newFile->newPivot($object, Arr::except($file, ['id']), 'fileables', true); + $newFile->setRelation('pivot', $pivot); + $filesCollection->push($newFile); + }); + + return $filesCollection; } } diff --git a/src/Repositories/Traits/ImagesTrait.php b/src/Repositories/Traits/ImagesTrait.php index f3eb91911..3a3188abd 100755 --- a/src/Repositories/Traits/ImagesTrait.php +++ b/src/Repositories/Traits/ImagesTrait.php @@ -9,9 +9,14 @@ trait ImagesTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveImagesTrait` during pending-only + * revision saves so {@see afterSaveImagesTrait} is skipped. + */ + protected bool $pendingBypassRevisionImagesTrait = true; + public function setColumnsImagesTrait($columns, $inputs) { - $traitName = get_class_short_name(__TRAIT__); $columns[$traitName] = collect($inputs)->reduce(function ($acc, $curr) { @@ -32,25 +37,109 @@ public function setColumnsImagesTrait($columns, $inputs) */ public function hydrateImagesTrait($object, $fields) { - // dd('hydrateImagesTrait', $object, $fields, $this->getMedias($fields)); if ($this->shouldIgnoreFieldBeforeSave('medias')) { return $object; } + $object->setRelation('medias', $this->getPreviewMedias($object, $fields)); + + return $object; + } + + /** + * Preview: merge DB medias with payload; omitted roles / locales keep persisted rows. + * + * @param array $fields + */ + private function getPreviewMedias($object, array $fields): Collection + { + $object->loadMissing('medias'); + + $roles = $this->resolveAttachmentRoles(__TRAIT__, '/image/', $fields, fn ($k, $v) => $this->valueLooksLikeImageRolePayload($v)); + $original = $object->medias; + + if (! collect($roles)->contains(fn ($role) => $this->attachmentRoleIsPresentInFields($fields, $role))) { + return $original; + } + + $out = Collection::make(); + + foreach ($roles as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + $out = $out->merge($original->where('pivot.role', $role)); + + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + $out = $out->merge($original->filter( + fn ($m) => $m->pivot->role === $role && $m->pivot->locale === $locale + )); + + continue; + } + + $slice = $rolePayload[$locale]; + if ($slice === null) { + continue; + } + + $rows = is_array($slice) ? $slice : []; + $acc = Collection::make(); + $specs = $this->pushImage($object, $acc, $rows, $role, $locale); + $out = $out->merge($this->pivotSpecsToMediaModels($object, $specs)); + } + } else { + $rows = is_array($payload) ? $payload : []; + $locale = (string) config('app.locale', 'en'); + $acc = Collection::make(); + $specs = $this->pushImage($object, $acc, $rows, $role, $locale); + $out = $out->merge($this->pivotSpecsToMediaModels($object, $specs)); + } + } + + $out = $out->merge($original->filter( + fn ($m) => ! in_array($m->pivot->role, $roles, true) + )); + + return $out->values(); + } + + /** + * @return Collection + */ + private function pivotSpecsToMediaModels($object, Collection $specs): Collection + { $mediasCollection = Collection::make(); - $mediasFromFields = $this->getMedias($object, $fields); + $specs->each(function ($spec) use ($object, $mediasCollection) { + if (! is_array($spec)) { + return; + } + + $mediaId = $spec['media_id'] ?? null; + $mediaId = is_array($mediaId) ? Arr::first($mediaId) : $mediaId; + if ($mediaId === null) { + return; + } + + $newMedia = Media::withTrashed()->find($mediaId); + if (! $newMedia) { + return; + } - $mediasFromFields->each(function ($media) use ($object, $mediasCollection) { - $newMedia = Media::withTrashed()->find(is_array($media['media_id']) ? Arr::first($media['media_id']) : $media['media_id']); - $pivot = $newMedia->newPivot($object, Arr::except($media, ['id']), modularityConfig('tables.mediables', 'umod_mediables'), true); + $pivot = $newMedia->newPivot($object, Arr::except($spec, ['id']), modularityConfig('tables.mediables', 'umod_mediables'), true); $newMedia->setRelation('pivot', $pivot); $mediasCollection->push($newMedia); }); - $object->setRelation('medias', $mediasCollection); - - return $object; + return $mediasCollection; } /** @@ -64,16 +153,84 @@ public function afterSaveImagesTrait($object, $fields) return; } - $this->getMedias($object, $fields)->each(function ($media) use ($object) { - if (isset($media['id']) && $media['id']) { - $result = $object->medias()->updateExistingPivot($media['id'], Arr::except($media, ['id', 'media_id'])); - if ($result) { - $this->mustTouchEloquentModel(); + $object->loadMissing('medias'); + + $roles = $this->resolveAttachmentRoles(__TRAIT__, '/image/', $fields, fn ($k, $v) => $this->valueLooksLikeImageRolePayload($v)); + + foreach ($roles as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if ($payload === null) { + continue; + } + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + continue; + } + + $slice = $rolePayload[$locale]; + $this->detachMediasForRoleLocale($object, $role, $locale); + + if ($slice === null) { + continue; + } + + $rows = is_array($slice) ? $slice : []; + $this->attachImageSpecsFromRows($object, $rows, $role, $locale); } } else { - $object->medias()->attach($media['media_id'], Arr::except($media, ['media_id'])); - $this->mustTouchEloquentModel(); + $locale = (string) config('app.locale', 'en'); + $this->detachMediasForRoleLocale($object, $role, $locale); + $rows = is_array($payload) ? $payload : []; + $this->attachImageSpecsFromRows($object, $rows, $role, $locale); + } + } + } + + /** + * Remove all media pivots for this role + locale so the next attach matches {@code fields} exactly. + */ + private function detachMediasForRoleLocale($object, string $role, string $locale): void + { + $relatedKey = $object->medias()->getRelated()->getQualifiedKeyName(); + $relation = $object->medias()->wherePivot('role', $role); + + if (modularityConfig('media_library.translated_form_fields', false)) { + $relation->wherePivot('locale', $locale); + } + + $ids = $relation->pluck($relatedKey); + + if ($ids->isEmpty()) { + return; + } + + $object->medias()->detach($ids->all()); + $this->mustTouchEloquentModel(); + } + + /** + * @param array $rows + */ + private function attachImageSpecsFromRows($object, array $rows, string $role, string $locale): void + { + $acc = Collection::make(); + $specs = $this->pushImage($object, $acc, $rows, $role, $locale); + + $specs->each(function ($media) use ($object) { + if (! is_array($media) || ! isset($media['media_id'])) { + return; } + + $object->medias()->attach($media['media_id'], Arr::except($media, ['media_id', 'id'])); + $this->mustTouchEloquentModel(); }); } @@ -84,7 +241,6 @@ public function afterSaveImagesTrait($object, $fields) */ public function getFormFieldsImagesTrait($object, $fields, $schema) { - // $t = []; $imageInputs = $this->getColumns(__TRAIT__); if (! empty($imageInputs) && $object->has('medias')) { $schema = $schema ?? $this->inputs(); @@ -124,38 +280,14 @@ public function getFormFieldsImagesTrait($object, $fields, $schema) return $fields; } - /** - * @param array $fields - * @return Collection - */ - private function getMedias($object, $fields) - { - $images = Collection::make(); - - $systemLocales = getLocales(); - - $imageRoles = $this->getColumns(__TRAIT__); - - foreach ($imageRoles as $role) { - if (isset($fields[$role])) { - foreach ($systemLocales as $locale) { - if (isset($fields[$role][$locale])) { - $images = $this->pushImage($object, $images, $fields[$role][$locale], $role, $locale); - } else { - $images = $this->pushImage($object, $images, $fields[$role], $role, $locale); - - } - } - } - } - - return $images; - } - public function pushImage($object, $images, $imagesData, $role, $locale, $index = null) { $mediablesTable = modularityConfig('tables.mediables', 'um_mediables'); Collection::make($imagesData)->each(function ($image) use ($object, $mediablesTable, &$images, $role, $locale, $index) { + if (! is_array($image) || ! isset($image['id'])) { + return; + } + $replacePattern = '/([A-Za-z-_]+)(\.)(\*)(\.)([A-Za-z-_\.]+)/'; $role = preg_replace($replacePattern, '${1}${2}' . $index . '${4}${5}', $role); $mediableId = $object->medias() @@ -168,7 +300,7 @@ public function pushImage($object, $images, $imagesData, $role, $locale, $index ...($mediableId ? ['id' => $mediableId] : []), 'media_id' => $image['id'], 'role' => $role, - 'metadatas' => json_encode($image['metadatas']), + 'metadatas' => json_encode($image['metadatas'] ?? []), 'crop' => 'default', 'locale' => $locale, ]); diff --git a/src/Repositories/Traits/PaymentTrait.php b/src/Repositories/Traits/PaymentTrait.php index 0d5a3a400..c56f9e0f8 100644 --- a/src/Repositories/Traits/PaymentTrait.php +++ b/src/Repositories/Traits/PaymentTrait.php @@ -18,6 +18,12 @@ trait PaymentTrait { use PricesTrait; + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSavePaymentTrait` during pending-only + * revision saves so {@see afterSavePaymentTrait} is skipped. + */ + protected bool $pendingBypassRevisionPaymentTrait = true; + /** * paymentTraitRelationName * diff --git a/src/Repositories/Traits/PricesTrait.php b/src/Repositories/Traits/PricesTrait.php index 927b9fed3..208a1f93b 100755 --- a/src/Repositories/Traits/PricesTrait.php +++ b/src/Repositories/Traits/PricesTrait.php @@ -11,6 +11,12 @@ trait PricesTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSavePricesTrait` during pending-only + * revision saves so {@see afterSavePricesTrait} is skipped. + */ + protected bool $pendingBypassRevisionPricesTrait = true; + protected $formatableColumns = [ 'id', 'raw_amount', diff --git a/src/Repositories/Traits/PublishableTrait.php b/src/Repositories/Traits/PublishableTrait.php new file mode 100644 index 000000000..1570a4a00 --- /dev/null +++ b/src/Repositories/Traits/PublishableTrait.php @@ -0,0 +1,18 @@ +getModel(), Publishable::class)) { + return []; + } + + return PublishableMetadata::defaultFormInputs(); + } +} diff --git a/src/Repositories/Traits/RepeatersTrait.php b/src/Repositories/Traits/RepeatersTrait.php index 84ab269e3..1d1f98406 100644 --- a/src/Repositories/Traits/RepeatersTrait.php +++ b/src/Repositories/Traits/RepeatersTrait.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Unusualify\Modularity\Entities\Model; +use Unusualify\Modularity\Entities\Repeater; /** * This trait is used for repeaters that may or may not have files or images in them. @@ -42,6 +43,103 @@ public function setColumnsRepeatersTrait($columns, $inputs) return $columns; } + /** + * Preview / {@see \Unusualify\Modularity\Repositories\Traits\RevisionsTrait::previewForRevision}: populate `repeaters` + * from the revision payload so presenters and forms see the same shape as after a normal load. + * + * @param Model $object + * @param array $fields + * @return Model + */ + public function hydrateRepeatersTrait($object, $fields) + { + if ($this->shouldIgnoreFieldBeforeSave('repeaters')) { + return $object; + } + + if (! classHasTrait($object, 'Unusualify\Modularity\Entities\Traits\HasRepeaters') || ! method_exists($object, 'repeaters')) { + return $object; + } + + $object->setRelation('repeaters', $this->buildPreviewRepeatersRelation($object, $fields)); + + return $object; + } + + /** + * Mirrors {@see afterSaveRepeatersTrait} field resolution so one {@see Repeater} row exists per role/locale + * with `content` taken from the payload (unsaved models for preview). + * + * @param array $fields + */ + private function buildPreviewRepeatersRelation(Model $object, array $fields): Collection + { + $out = Collection::make(); + $schema = $this->getRawInputs(); + $systemLocales = getLocales(); + $fallbackLocale = app()->getFallbackLocale(); + + foreach ($this->getRepeaterInputs($schema) as $input) { + $name = $input['name']; + $isTranslated = $input['translated'] ?? false; + + if (! isset($fields[$name]) || ! is_array($fields[$name])) { + continue; + } + + $intersectLocales = array_intersect(array_keys($fields[$name]), $systemLocales); + $localized = count($intersectLocales) > 1; + $existLocale = $localized ? ($intersectLocales[0] ?? null) : null; + + if ($isTranslated) { + foreach ($systemLocales as $systemLocale) { + $content = $fields[$name]; + if ($localized) { + $content = isset($fields[$name][$systemLocale]) + ? $fields[$name][$systemLocale] + : ($existLocale ? ($fields[$name][$existLocale] ?? []) : []); + } + if (! is_array($content)) { + $content = []; + } + + $out->push($this->makePreviewRepeaterRow($object, $name, $systemLocale, $content)); + } + } else { + $payload = $fields[$name]; + if ($localized) { + $payload = isset($fields[$name][$fallbackLocale]) + ? $fields[$name][$fallbackLocale] + : ($existLocale ? ($fields[$name][$existLocale] ?? []) : []); + } + if (! is_array($payload)) { + $payload = []; + } + + $out->push($this->makePreviewRepeaterRow($object, $name, $fallbackLocale, $payload)); + } + } + + return $out; + } + + /** + * @param array $content + */ + private function makePreviewRepeaterRow(Model $object, string $role, string $locale, array $content): Repeater + { + $repeater = new Repeater([ + 'role' => $role, + 'locale' => $locale, + 'content' => $content, + 'repeatable_id' => $object->getKey(), + 'repeatable_type' => $object->getMorphClass(), + ]); + $repeater->exists = false; + + return $repeater; + } + /** * @param Model $object * @param array $fields diff --git a/src/Repositories/Traits/RevisionsTrait.php b/src/Repositories/Traits/RevisionsTrait.php new file mode 100644 index 000000000..fe3a8ff31 --- /dev/null +++ b/src/Repositories/Traits/RevisionsTrait.php @@ -0,0 +1,600 @@ +setSchema($schema); + $this->setColumns($schema ?? $this->chunkInputs(all: true)); + + if (classHasTrait($this->model, 'Unusualify\Modularity\Entities\Traits\IsSingular')) { + $object = $this->model->single(); + } else { + $object = $this->model->findOrFail($id); + } + + if ($this->shouldQueuePendingRevisionOnly($object, $fields)) { + $this->beforeSave($object, $fields); + + $preparedFields = $this->prepareFieldsBeforeSave($object, $fields); + + if ($this->processPendingRevisionSubmission($object, $preparedFields)) { + $object = $this->touchEloquentModel($object->fresh()); + $this->dispatchEvent($object, 'update'); + + return true; + } + } + + return parent::update($id, $fields, $schema, $options); + } + + public function beforeSaveRevisionsTrait($object, $fields): void + { + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + return; + } + + if ($this->workflowBypassPendingGuard) { + return; + } + + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + $message = __('messages.revision.pending-locks-record'); + + throw ValidationException::withMessages([ + 'revision' => [$message], + ])->variant('warning'); + } + } + + public function afterSaveRevisionsTrait($object, $fields): void + { + $this->createRevisionIfNeeded($object, $fields); + } + + /** + * @param \Unusualify\Modularity\Models\Model $object + * @return array + */ + public function getFormFieldsRevisionsTrait($object, $fields, $schema = []) + { + // set, cast, unset or manipulate the fields by using object, fields and schema + if (isset($schema['revisionable_id'])) { + $fields['revisionable_id'] = $object?->id; + } + + return $fields; + } + + public function createRevisionIfNeeded($object, array $fields): array + { + if ($this->skipRevisionCreation) { + return $fields; + } + + $lastRevisionPayload = $this->getLastApprovedRevisionPayload($object); + + $fullPayload = array_replace_recursive($lastRevisionPayload, $fields); + + if ($this->revisionPayloadsAreEquivalent($fullPayload, $lastRevisionPayload)) { + return $fields; + } + + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + + $revisionAttributes = [ + 'payload' => json_encode($fullPayload), + 'user_id' => $userId, + 'source_id' => $this->pendingSourceRevisionId, + ]; + + if ($this->revisionTableHasStatusColumn($object)) { + $this->applyApprovedRevisionAttributes($revisionAttributes, $userId); + } + + $object->revisions()->create($revisionAttributes); + + if (isset($object->limitRevisions) && (int) $object->limitRevisions > 0) { + $object->deleteSpecificRevisions((int) $object->limitRevisions); + } + + return $fields; + } + + public function preview(int $id, array $fields) + { + $object = $this->model->findOrFail($id); + + return $this->hydrateObject($object, $fields); + } + + public function previewForRevision(int $id, int $revisionId, $schema = []) + { + $this->setSchema($schema); + $this->setColumns($schema ?? $this->chunkInputs(all: true)); + + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + $fields = json_decode($revision->payload, true) ?: []; + + return $this->hydrateObject($this->model->newInstance()->setAttribute('id', $id), $fields); + } + + public function restoreRevision(int $id, int $revisionId) + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + if ($this->revisionTableHasStatusColumn($object) && ($revision->status ?? RevisionStatus::Approved->value) === RevisionStatus::Rejected->value) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.restore-blocked-rejected')], + ]); + } + + if (method_exists($object, 'usesRevisionWorkflow') && $object->usesRevisionWorkflow()) { + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.restore-blocked-pending')], + ]); + } + + if (! $object->userCanRestoreRevisions()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.restore-forbidden')], + ]); + } + } + + if ($revision->source_id !== null) { + abort(422, __('messages.revision.restore-disabled-already-restored')); + } + + $fields = json_decode($revision->payload, true) ?: []; + + if ($this->shouldRestoreAsPendingOnly($object)) { + return $this->restoreRevisionAsPendingOnly($object, $fields, $revisionId); + } + + // Skip auto-revision creation during update so we can force-create one below, + // ensuring a restore is always recorded even when content is identical to the latest revision. + $this->skipRevisionCreation = true; + $this->update($id, $fields); + $this->skipRevisionCreation = false; + + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + $restoreAttributes = [ + 'payload' => json_encode($fields), + 'user_id' => $userId, + 'source_id' => $revisionId, + ]; + if ($this->revisionTableHasStatusColumn($object)) { + $this->applyApprovedRevisionAttributes($restoreAttributes, $userId); + } + + $object->revisions()->create($restoreAttributes); + + return $this->model->findOrFail($id); + } + + /** + * Workflow on + user lacks {@code *_revision_approve}: restore only queues a pending snapshot (subject row unchanged), like a normal edit. + * + * @param \Unusualify\Modularity\Models\Model $object + */ + protected function shouldRestoreAsPendingOnly($object): bool + { + if (! $this->revisionTableHasStatusColumn($object)) { + return false; + } + + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + return false; + } + + return ! $object->userCanApproveRevisions(); + } + + /** + * Record a proposed restore as the latest pending revision without persisting payload to the subject. + * + * @param \Unusualify\Modularity\Models\Model $object + */ + protected function restoreRevisionAsPendingOnly($object, array $fields, int $sourceRevisionId): mixed + { + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.pending-only-one')], + ]); + } + + $this->skipRevisionCreation = true; + + try { + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + + $revisionAttributes = [ + 'payload' => json_encode($fields), + 'user_id' => $userId, + 'source_id' => $sourceRevisionId, + ]; + + if ($this->revisionTableHasStatusColumn($object)) { + $revisionAttributes['status'] = RevisionStatus::Pending->value; + $revisionAttributes['approved_at'] = null; + $revisionAttributes['approved_by'] = null; + } + + $object->revisions()->create($revisionAttributes); + + $this->bypassAfterSaves(); + try { + $this->afterSave($object, $fields); + } finally { + $this->resetPassAfterSaves(); + } + } finally { + $this->skipRevisionCreation = false; + } + + return $this->model->findOrFail($object->id); + } + + /** + * Sets passAfterSave* flags when a composing trait opts in via traitProperties('pendingBypassRevision'). + * Only traits that declare pendingBypassRevision{TraitBasename} participate; when that flag is true, + * passAfterSave{SameSuffix} is set so the corresponding afterSave hook is skipped during pending-only saves. + * + * File / Filepond: when bypassed, Filepond::saveFile does not run; the revision JSON must still store upload + * response metadata (ids, paths). On approve, a normal afterSave finalizes. Mitigate temp expiry via longer TTL, + * staging disk, or a dedicated “promote temp file to library without attaching to live row” step. + */ + protected function bypassAfterSaves(): void + { + foreach ($this->traitProperties('pendingBypassRevision') as $pendingKey) { + if (! $this->{$pendingKey}) { + continue; + } + + $suffix = (string) preg_replace('/^pendingBypassRevision/', '', $pendingKey); + + if ($suffix === 'RevisionsTrait') { + continue; + } + + $passKey = 'passAfterSave' . $suffix; + + if (property_exists($this, $passKey)) { + $this->{$passKey} = true; + } + } + } + + protected function resetPassAfterSaves(): void + { + foreach ($this->traitProperties('passAfterSave') as $passKey) { + $this->{$passKey} = false; + } + } + + /** + * @param array $attributes + */ + protected function applyApprovedRevisionAttributes(array &$attributes, $userId): void + { + $attributes['status'] = RevisionStatus::Approved->value; + $attributes['approved_at'] = now(); + $attributes['approved_by'] = $userId; + } + + /** + * Apply a pending revision payload to the subject and mark the revision approved. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function approveRevision(int $id, int $revisionId) + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + abort(422, __('messages.revision.approve-not-applicable')); + } + + $latest = $object->revisions()->orderByDesc('id')->first(); + + if (! $latest || (int) $latest->id !== (int) $revision->id) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.approve-not-latest')], + ]); + } + + if ($this->revisionTableHasStatusColumn($object) && ! $revision->isPending()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.approve-not-pending')], + ]); + } + + $fields = json_decode($revision->payload, true) ?: []; + + $this->workflowBypassPendingGuard = true; + $this->skipRevisionCreation = true; + + try { + $this->update($id, $fields); + + if ($this->revisionTableHasStatusColumn($object)) { + $revision->refresh(); + $revision->update([ + 'status' => RevisionStatus::Approved->value, + 'approved_at' => now(), + 'approved_by' => Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(), + ]); + } + } finally { + $this->workflowBypassPendingGuard = false; + $this->skipRevisionCreation = false; + } + + return $this->model->findOrFail($id); + } + + /** + * Mark the latest pending revision as rejected. Does not update the subject row. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function rejectRevision(int $id, int $revisionId) + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + abort(422, __('messages.revision.reject-not-applicable')); + } + + $latest = $object->revisions()->orderByDesc('id')->first(); + + if (! $latest || (int) $latest->id !== (int) $revision->id) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.reject-not-latest')], + ]); + } + + if ($this->revisionTableHasStatusColumn($object) && ! $revision->isPending()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.reject-not-pending')], + ]); + } + + if ($this->revisionTableHasStatusColumn($object)) { + $revision->update([ + 'status' => RevisionStatus::Rejected->value, + 'approved_at' => null, + 'approved_by' => null, + ]); + } + + return $this->model->findOrFail($id); + } + + public function getRevisionPayload(int $id, int $revisionId): array + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + return json_decode($revision->payload, true) ?: []; + } + + public function getCountForMine(): int + { + $query = $this->model->newQuery(); + + return $this->filter($query, $this->countScope)->mine()->count(); + } + + public function getCountByStatusSlugRevisionsTrait(string $slug): int|bool + { + if ($slug === 'mine') { + return $this->getCountForMine(); + } + + return false; + } + + protected function hydrateObject($object, array $fields) + { + $fields = $this->prepareFieldsBeforeSave($object, $fields); + $object->fill(Arr::except($fields, $this->getReservedFields())); + + return $this->hydrate($object, $fields); + } + + public function getRevisions(int $id) + { + $revisionModel = $this->model->getRevisionModel(); + $revisions = $revisionModel::where($this->model->getForeignKey(), $id) + ->orderBy('created_at', 'desc') + ->get(); + + return $revisions; + } + + protected function shouldQueuePendingRevisionOnly($object, array $fields): bool + { + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + return false; + } + + if ($this->workflowBypassPendingGuard) { + return false; + } + + if (! $object->userCanApproveRevisions()) { + return true; + } + + return false; + } + + /** + * @param \Unusualify\Modularity\Models\Model $object + * @return bool false when merged payload matches last approved (nothing new to queue) + */ + protected function processPendingRevisionSubmission($object, array $fields): bool + { + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.pending-only-one')], + ]); + } + + $lastPayload = $this->getLastApprovedRevisionPayload($object); + $fullPayload = array_replace_recursive($lastPayload, $fields); + + if ($this->revisionPayloadsAreEquivalent($fullPayload, $lastPayload)) { + return false; + } + + $this->skipRevisionCreation = true; + + try { + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + + $revisionAttributes = [ + 'payload' => json_encode($fullPayload), + 'user_id' => $userId, + 'source_id' => $this->pendingSourceRevisionId, + ]; + + if ($this->revisionTableHasStatusColumn($object)) { + $revisionAttributes['status'] = RevisionStatus::Pending->value; + $revisionAttributes['approved_at'] = null; + $revisionAttributes['approved_by'] = null; + } + + $object->revisions()->create($revisionAttributes); + + $this->bypassAfterSaves(); + try { + $this->afterSave($object, $fields); + } finally { + $this->resetPassAfterSaves(); + } + + return true; + } finally { + $this->skipRevisionCreation = false; + } + } + + /** + * Compare revision payloads without regard to associative key order (PHP's array === is order-sensitive). + * + * @param array $a + * @param array $b + */ + protected function revisionPayloadsAreEquivalent(array $a, array $b): bool + { + $na = $this->normalizeRevisionPayloadForComparison($a); + $nb = $this->normalizeRevisionPayloadForComparison($b); + + return json_encode($na, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + === json_encode($nb, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * @param array $value + * @return array|mixed + */ + protected function normalizeRevisionPayloadForComparison(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + if (array_is_list($value)) { + return array_map(fn ($item) => $this->normalizeRevisionPayloadForComparison($item), $value); + } + + ksort($value); + + foreach ($value as $k => $v) { + $value[$k] = $this->normalizeRevisionPayloadForComparison($v); + } + + return $value; + } + + /** + * @param \Unusualify\Modularity\Models\Model $object + */ + protected function revisionTableHasStatusColumn($object): bool + { + $modelClass = $object->getRevisionModel(); + $instance = new $modelClass; + + return Schema::hasColumn($instance->getTable(), 'status'); + } + + /** + * Payload merged from the latest approved (or legacy unmarked) revision. + * + * @param \Unusualify\Modularity\Models\Model $object + * @return array + */ + public function getLastApprovedRevisionPayload($object): array + { + $query = $object->revisions()->orderByDesc('id'); + + if ($this->revisionTableHasStatusColumn($object)) { + $query->where(function ($q) { + $q->where('status', RevisionStatus::Approved->value) + ->orWhereNull('status'); + }); + } + + $revision = $query->first(); + + return json_decode($revision->payload ?? '{}', true) ?: []; + } +} diff --git a/src/Repositories/Traits/SlugsTrait.php b/src/Repositories/Traits/SlugsTrait.php index d32f2a0c5..1f726f84d 100755 --- a/src/Repositories/Traits/SlugsTrait.php +++ b/src/Repositories/Traits/SlugsTrait.php @@ -6,6 +6,174 @@ trait SlugsTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveSlugsTrait` during pending-only + * revision saves so {@see afterSaveSlugsTrait} is skipped. + */ + protected bool $pendingBypassRevisionSlugsTrait = true; + + /** + * Skip {@see HasSlug}'s automatic {@see setSlugs()} on {@see Model::saved} when the client posts explicit + * per-locale slug payloads (see `$fields['slugs']`) or when {@see prepareFieldsBeforeSaveSlugsTrait} merged + * slug input from nested request shapes. Otherwise {@see setSlugs()} may still run from {@see $slugAttributes}. + */ + public function beforeSaveSlugsTrait(Model $object, array $fields): void + { + if (! property_exists($this->model, 'slugAttributes')) { + return; + } + + $object->modularitySkipAutomaticSlugSync = $this->shouldSkipAutomaticSlugSyncOnSave($object, $fields); + } + + /** + * @param array $fields Raw request fields (before {@see prepareFieldsBeforeSave} transforms). + */ + protected function shouldSkipAutomaticSlugSyncOnSave(Model $object, array $fields): bool + { + if ($this->requestArrayContainsEditorSlugPayload($fields['slugs'] ?? null)) { + return true; + } + + if ($this->requestArrayContainsEditorSlugPayload($fields['translations']['slugs'] ?? null)) { + return true; + } + + foreach ($object->getSlugAttributes() as $attr) { + if (array_key_exists($attr, $fields)) { + return false; + } + + if (isset($fields['translations']) && is_array($fields['translations']) && array_key_exists($attr, $fields['translations'])) { + return false; + } + } + + return true; + } + + /** + * @param mixed $slugByLocale + */ + protected function requestArrayContainsEditorSlugPayload(mixed $slugByLocale): bool + { + if (! is_array($slugByLocale)) { + return false; + } + + foreach (getLocales() as $locale) { + if ($this->slugLocalePayloadIsEditorProvided($slugByLocale[$locale] ?? null)) { + return true; + } + } + + return false; + } + + /** + * Canonical slug payloads for {@see afterSaveSlugsTrait} live under `$fields['slugs'][locale]`. + * Never copy {@see HasSlug::$slugAttributes} (derivation sources) into `$fields['slugs']`; those inform + * {@see HasSlug::setSlugs()} on save when no explicit slug input is posted. + * Only merges alternate request shapes into `$fields['slugs']` (translations layout / per-locale bucket). + * + * @param Model $object + * @param array $fields + * @return array + */ + public function prepareFieldsBeforeSaveSlugsTrait($object, $fields) + { + if (! property_exists($this->model, 'slugAttributes')) { + return $fields; + } + + if ($object->getSlugAttributes() === []) { + return $fields; + } + + $fields['slugs'] = isset($fields['slugs']) && is_array($fields['slugs']) ? $fields['slugs'] : []; + + foreach (getLocales() as $locale) { + if ($this->slugInputPayloadIsPresent($fields['slugs'][$locale] ?? null)) { + continue; + } + + $fromTranslationsNested = $fields['translations']['slugs'][$locale] ?? null; + $fromLocaleBucket = isset($fields[$locale]) && is_array($fields[$locale]) + ? ($fields[$locale]['slugs'] ?? null) + : null; + + foreach ([$fromTranslationsNested, $fromLocaleBucket] as $candidate) { + if ($this->slugInputPayloadIsPresent($candidate)) { + $fields['slugs'][$locale] = $candidate; + + break; + } + } + } + + if ($this->mergedSlugPayloadRequestsExplicitManagement($fields)) { + $object->modularitySkipAutomaticSlugSync = true; + } + + return $fields; + } + + /** + * After merge, at least one locale carries slug input the editor controls (non-empty slug and/or explicit `active`). + */ + protected function mergedSlugPayloadRequestsExplicitManagement(array $fields): bool + { + if (! isset($fields['slugs']) || ! is_array($fields['slugs'])) { + return false; + } + + foreach (getLocales() as $locale) { + if ($this->slugLocalePayloadIsEditorProvided($fields['slugs'][$locale] ?? null)) { + return true; + } + } + + return false; + } + + /** + * True when this locale's slug bucket should run {@see afterSaveSlugsTrait} and should not be treated as empty. + */ + protected function slugLocalePayloadIsEditorProvided(mixed $payload): bool + { + if ($payload === null || $payload === '') { + return false; + } + + if (is_array($payload)) { + $hasSlug = array_key_exists('slug', $payload) + && $payload['slug'] !== null + && $payload['slug'] !== ''; + + return $hasSlug || array_key_exists('active', $payload); + } + + return true; + } + + /** + * Used when merging alternate request shapes into `$fields['slugs']` (slug text must be non-empty). + * + * @param mixed $payload + */ + protected function slugInputPayloadIsPresent(mixed $payload): bool + { + if ($payload === null || $payload === '') { + return false; + } + + if (is_array($payload)) { + return array_key_exists('slug', $payload) && $payload['slug'] !== null && $payload['slug'] !== ''; + } + + return true; + } + /** * @param Model $object * @param array $fields @@ -15,16 +183,29 @@ public function afterSaveSlugsTrait($object, $fields) { if (property_exists($this->model, 'slugAttributes')) { foreach (getLocales() as $locale) { - if (isset($fields['slugs']) && isset($fields['slugs'][$locale]) && ! empty($fields['slugs'][$locale])) { + if ( + isset($fields['slugs'][$locale]) + && $this->slugLocalePayloadIsEditorProvided($fields['slugs'][$locale]) + ) { $slugValue = $fields['slugs'][$locale]; $isArray = is_array($slugValue); $object->disableLocaleSlugs($locale); $currentSlug = []; - $currentSlug['slug'] = $isArray ? $slugValue['slug'] : $slugValue; + $currentSlug['slug'] = $isArray ? ($slugValue['slug'] ?? '') : $slugValue; $currentSlug['locale'] = $locale; - $currentSlug['active'] = ($this->model->isTranslatable() && isset($object->translations) && count($object->translations) > 0 && ! ($isArray && isset($slugValue['active']))) - ? $object->translate($locale)->active - : ($isArray && isset($slugValue['active']) ? (bool) $slugValue['active'] : 1); + $slugPayloadCanForceActive = + $this->model->isTranslatable() + && method_exists($object, 'slugPrimaryAttributeIsTranslated') + && $object->slugPrimaryAttributeIsTranslated() + && isset($object->translations) + && count($object->translations) > 0 + && ! ($isArray && array_key_exists('active', $slugValue)); + + $currentSlug['active'] = $slugPayloadCanForceActive + ? true + : ($isArray && array_key_exists('active', $slugValue) + ? $object->normalizeSlugActiveRequestValue($slugValue['active']) + : true); $currentSlug = $this->getSlugParameters($object, $fields, $currentSlug); $object->updateOrNewSlug($currentSlug); @@ -60,11 +241,29 @@ public function getFormFieldsSlugsTrait($object, $fields) { unset($fields['slugs']); - if ($object->slugs != null) { - foreach ($object->slugs as $slug) { - if ($slug->active || $object->slugs->where('locale', $slug->locale)->where('active', true)->count() === 0) { - $fields['translations']['slug'][$slug->locale] = $slug->slug; - } + if (! property_exists($this->model, 'slugAttributes')) { + return $fields; + } + + $slugAttributes = $object->getSlugAttributes(); + + if ($slugAttributes === []) { + return $fields; + } + + $object->loadMissing('slugs'); + + if ($object->slugs === null || $object->slugs->isEmpty()) { + return $fields; + } + + foreach ($object->slugs as $slug) { + $hasOtherActiveForLocale = $object->slugs->where('locale', $slug->locale)->where('active', true)->isNotEmpty(); + if ($slug->active || ! $hasOtherActiveForLocale) { + $fields['slugs'][$slug->locale] = [ + 'slug' => $slug->slug, + 'active' => (bool) $slug->active, + ]; } } diff --git a/src/Repositories/Traits/SpreadableTrait.php b/src/Repositories/Traits/SpreadableTrait.php index 9d7fc73ed..dbc6518f4 100644 --- a/src/Repositories/Traits/SpreadableTrait.php +++ b/src/Repositories/Traits/SpreadableTrait.php @@ -24,6 +24,10 @@ protected function setColumnsSpreadableTrait($columns, $inputs) protected function beforeSaveSpreadableTrait($object, $fields) { + if (method_exists($this, 'shouldQueuePendingRevisionOnly') && $this->shouldQueuePendingRevisionOnly($object, $fields)) { + return; + } + // Get the spreadable model instance $spreadableModel = $object->spreadable()->first(); diff --git a/src/Repositories/Traits/TagsTrait.php b/src/Repositories/Traits/TagsTrait.php index f7cc78faf..67a023a1e 100755 --- a/src/Repositories/Traits/TagsTrait.php +++ b/src/Repositories/Traits/TagsTrait.php @@ -8,6 +8,12 @@ trait TagsTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveTagsTrait` during pending-only + * revision saves so {@see afterSaveTagsTrait} is skipped. + */ + protected bool $pendingBypassRevisionTagsTrait = true; + public function setColumnsTagsTrait($columns, $inputs) { $traitName = get_class_short_name(__TRAIT__); diff --git a/src/Repositories/Traits/TranslatableMetadataTrait.php b/src/Repositories/Traits/TranslatableMetadataTrait.php new file mode 100644 index 000000000..0ea8b67b6 --- /dev/null +++ b/src/Repositories/Traits/TranslatableMetadataTrait.php @@ -0,0 +1,30 @@ + $scope + * @return list> + */ + public function appendFormSchemaTranslatableMetadataTrait($scope = []): array + { + if (! $this->hasModelTrait(HasTranslatableMetadata::class)) { + return []; + } + + return TranslatableMetadata::defaultFormInputs(); + } +} diff --git a/src/Repositories/Traits/TranslationsTrait.php b/src/Repositories/Traits/TranslationsTrait.php index 0edf79866..14ade5dda 100755 --- a/src/Repositories/Traits/TranslationsTrait.php +++ b/src/Repositories/Traits/TranslationsTrait.php @@ -77,9 +77,8 @@ public function prepareFieldsBeforeSaveTranslationsTrait($object, $fields) $activeField = $shouldPublishFirstLanguage || (isset($submittedLanguage) ? $submittedLanguage['published'] : false); - $fields[$locale] = [ - 'active' => $activeField, - ] + $attributes->mapWithKeys(function ($attribute) use (&$fields, $locale, $localesCount, $index, $translationsFields) { + + $fields[$locale] = $attributes->mapWithKeys(function ($attribute) use (&$fields, $locale, $localesCount, $index, $translationsFields) { $attributeValue = $fields[$attribute] ?? $translationsFields[$attribute] ?? null; // if we are at the last locale, @@ -88,11 +87,15 @@ public function prepareFieldsBeforeSaveTranslationsTrait($object, $fields) unset($fields[$attribute]); } + $perLocale = isset($attributeValue[$locale]) ? $attributeValue[$locale] : null; + $perLocale = $this->normalizeSlugPayloadForTranslationColumn($attribute, $perLocale); + return [ - // $attribute => ($attributeValue[$locale] ?? null), - $attribute => ($attributeValue[$locale] ?? $attributeValue ?? null), + $attribute => $perLocale, ]; - })->toArray(); + })->toArray() + [ + 'active' => $activeField, + ]; } } @@ -103,6 +106,28 @@ public function prepareFieldsBeforeSaveTranslationsTrait($object, $fields) return $fields; } + /** + * Slug input (manageActive) submits `{ slug: string, active: bool }` per locale; translation rows store only the slug string. + */ + protected function normalizeSlugPayloadForTranslationColumn(string $attribute, mixed $value): mixed + { + if (! is_array($value) || ! array_key_exists('slug', $value)) { + return $value; + } + + if (! method_exists($this->model, 'getSlugAttributes')) { + return $value; + } + + if (! in_array($attribute, $this->model->getSlugAttributes(), true)) { + return $value; + } + + $slug = $value['slug']; + + return $slug === null || $slug === '' ? '' : (string) $slug; + } + /** * @param Model $object * @param array $fields @@ -110,8 +135,10 @@ public function prepareFieldsBeforeSaveTranslationsTrait($object, $fields) */ public function getFormFieldsTranslationsTrait($object, $fields) { - unset($fields['translations']); $translatedAttributes = $object->getTranslatedAttributes(); + + unset($fields['translations']); + if ($object->translations != null && $translatedAttributes != null) { foreach ($object->translations as $translation) { foreach ($translatedAttributes as $attribute) { @@ -205,6 +232,36 @@ public function orderTranslationsTrait($query, &$orders) } } + /** + * After save, re-enable timestamps and touch the parent model + * only when a translation row really changed (ignoring auto-timestamp + * columns that the translation table may carry). + * + * @param \Illuminate\Database\Eloquent\Model $object + * @param array $fields + * @return void + */ + public function afterSaveTranslationsTrait($object, $fields) + { + if (! $this->model->isTranslatable()) { + return; + } + + if ($object->relationLoaded('translations')) { + $timestampKeys = ['updated_at', 'created_at', 'deleted_at']; + + foreach ($object->translations as $translation) { + $changedKeys = array_keys($translation->getChanges()); + $meaningfulChanges = array_diff($changedKeys, $timestampKeys); + + if (! empty($meaningfulChanges)) { + $this->letEloquentModelBeTouched(true); + break; + } + } + } + } + /** * @return array */ diff --git a/src/Services/BulkCsv/BulkCsvImportOrchestrator.php b/src/Services/BulkCsv/BulkCsvImportOrchestrator.php new file mode 100644 index 000000000..df0586dc2 --- /dev/null +++ b/src/Services/BulkCsv/BulkCsvImportOrchestrator.php @@ -0,0 +1,254 @@ +>, + * summary: array + * } + */ + public function import(string $csvContent, bool $dryRun, CanBulkSheet $bulkSheet, string $toolKey): array + { + $parsed = $this->parseCsv($csvContent, $bulkSheet); + + if (! $parsed['ok']) { + return [ + 'ok' => false, + 'dry_run' => $dryRun, + 'tool_key' => $toolKey, + 'message' => $parsed['error'] ?? 'Invalid CSV.', + 'rows' => [], + 'summary' => [ + 'total' => 0, + 'valid' => 0, + 'invalid' => 0, + 'warnings' => 0, + ], + ]; + } + + $prepared = $bulkSheet->bulkSheetPrepareAndValidateRows($parsed['records']); + + $invalidCount = collect($prepared)->where('valid', false)->count(); + $validCount = collect($prepared)->where('valid', true)->count(); + $warningCount = collect($prepared)->sum(fn ($r) => count($r['warnings'] ?? [])); + + if ($invalidCount > 0) { + return [ + 'ok' => false, + 'dry_run' => $dryRun, + 'tool_key' => $toolKey, + 'message' => 'One or more rows failed validation.', + 'rows' => $prepared, + 'summary' => [ + 'total' => count($prepared), + 'valid' => $validCount, + 'invalid' => $invalidCount, + 'warnings' => $warningCount, + ], + ]; + } + + if ($dryRun) { + $created = 0; + $updated = 0; + foreach ($prepared as $row) { + if (($row['action'] ?? '') === 'create') { + $created++; + } elseif (($row['action'] ?? '') === 'update') { + $updated++; + } + } + + return [ + 'ok' => true, + 'dry_run' => true, + 'tool_key' => $toolKey, + 'rows' => $prepared, + 'summary' => [ + 'total' => count($prepared), + 'valid' => $validCount, + 'invalid' => 0, + 'warnings' => $warningCount, + 'created' => $created, + 'updated' => $updated, + ], + ]; + } + + $counts = ['created' => 0, 'updated' => 0]; + + DB::transaction(function () use ($bulkSheet, $prepared, &$counts): void { + $counts = $bulkSheet->bulkSheetCommitPreparedRows($prepared); + }, 3); + + return [ + 'ok' => true, + 'dry_run' => false, + 'tool_key' => $toolKey, + 'rows' => $prepared, + 'summary' => [ + 'total' => count($prepared), + 'valid' => $validCount, + 'invalid' => 0, + 'warnings' => $warningCount, + 'created' => $counts['created'] ?? 0, + 'updated' => $counts['updated'] ?? 0, + ], + ]; + } + + public function streamExport(CanBulkSheet $bulkSheet, ?string $filename = null): StreamedResponse + { + $filename = $filename ?? $bulkSheet->bulkSheetExportDownloadFilename(); + + $filename = date('Y-m-d_His') . '_' . $filename; + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]; + + return response()->stream(function () use ($bulkSheet): void { + $out = fopen('php://output', 'w'); + if ($out === false) { + return; + } + $bulkSheet->bulkSheetStreamExport($out); + fclose($out); + }, 200, $headers); + } + + /** + * Canonical field key => CSV header synonyms (lowercase, without spaces). First synonym is the export header. + * + * @return array> + */ + public function csvCanonicalToHeaderAliases(CanBulkSheet $bulkSheet): array + { + $out = []; + foreach ($bulkSheet->bulkSheetFields() as $field) { + $out[$field['key']] = array_values(array_unique(array_merge( + [$field['key']], + $field['aliases'] ?? [] + ))); + } + + return $out; + } + + /** + * @return array{ok: bool, error?: string, records?: list}>} + */ + protected function parseCsv(string $csvContent, CanBulkSheet $bulkSheet): array + { + $csvContent = preg_replace('/^\xEF\xBB\xBF/', '', $csvContent) ?? $csvContent; + $csvContent = str_replace("\r\n", "\n", $csvContent); + $csvContent = str_replace("\r", "\n", $csvContent); + $lines = array_filter(explode("\n", $csvContent), static fn ($l) => trim((string) $l) !== ''); + + if ($lines === []) { + return ['ok' => false, 'error' => 'CSV is empty.']; + } + + $headerLine = (string) array_shift($lines); + $delimiter = $this->detectCsvDelimiter($headerLine, $bulkSheet); + $headerCells = str_getcsv($headerLine, $delimiter); + + $columnMap = $this->mapHeaderRow($headerCells, $bulkSheet); + + if ($columnMap === null) { + return [ + 'ok' => false, + 'error' => 'CSV is missing required columns. Expected headers: ' . $this->expectedHeadersHint($bulkSheet), + ]; + } + + $canonicalKeys = array_keys($this->csvCanonicalToHeaderAliases($bulkSheet)); + + $records = []; + $lineNumber = 2; + + foreach ($lines as $line) { + $values = str_getcsv((string) $line, $delimiter); + $row = []; + foreach ($canonicalKeys as $canonical) { + $idx = $columnMap[$canonical] ?? null; + $row[$canonical] = $idx !== null ? trim((string) ($values[$idx] ?? '')) : ''; + } + $records[] = ['line' => $lineNumber, 'values' => $row]; + $lineNumber++; + } + + return ['ok' => true, 'records' => $records]; + } + + /** + * Prefer comma (RFC-style), then semicolon (common Excel EU locale), then tab. + */ + protected function detectCsvDelimiter(string $headerLine, CanBulkSheet $bulkSheet): string + { + foreach ([',', ';', "\t"] as $delimiter) { + $cells = str_getcsv($headerLine, $delimiter); + if ($this->mapHeaderRow($cells, $bulkSheet) !== null) { + return $delimiter; + } + } + + return ','; + } + + /** + * @param list $headerCells + * @return array|null canonical => column index + */ + protected function mapHeaderRow(array $headerCells, CanBulkSheet $bulkSheet): ?array + { + $headerToCanonical = []; + foreach ($this->csvCanonicalToHeaderAliases($bulkSheet) as $canonical => $aliases) { + foreach ($aliases as $alias) { + $headerToCanonical[mb_strtolower(trim($alias))] = $canonical; + } + } + + $map = []; + foreach ($headerCells as $i => $h) { + $low = mb_strtolower(trim((string) $h)); + if (isset($headerToCanonical[$low])) { + $map[$headerToCanonical[$low]] = $i; + } + } + + foreach ($bulkSheet->bulkSheetFields() as $field) { + if (($field['required'] ?? false) && ! isset($map[$field['key']])) { + return null; + } + } + + return $map; + } + + protected function expectedHeadersHint(CanBulkSheet $bulkSheet): string + { + $parts = []; + foreach ($bulkSheet->bulkSheetFields() as $field) { + $aliases = array_merge([$field['key']], $field['aliases'] ?? []); + $parts[] = implode('/', $aliases); + } + + return implode(', ', $parts); + } +} diff --git a/src/Services/BulkCsv/BulkImportService.php b/src/Services/BulkCsv/BulkImportService.php new file mode 100644 index 000000000..b1e7476e3 --- /dev/null +++ b/src/Services/BulkCsv/BulkImportService.php @@ -0,0 +1,29 @@ + + */ + public function import(string $csvContent, bool $dryRun, CanBulkSheet $bulkSheet, string $toolKey): array + { + return $this->orchestrator->import($csvContent, $dryRun, $bulkSheet, $toolKey); + } + + public function streamExport(CanBulkSheet $bulkSheet, ?string $filename = null): StreamedResponse + { + return $this->orchestrator->streamExport($bulkSheet, $filename); + } +} diff --git a/src/Services/FilepondManager.php b/src/Services/FilepondManager.php index e3170c944..75082f83e 100644 --- a/src/Services/FilepondManager.php +++ b/src/Services/FilepondManager.php @@ -68,7 +68,6 @@ public function deleteTemporaryFilepond(Request $request) public function previewFile($folder) { - // dd($folder); if (Storage::exists($this->file_path . '/' . $folder)) { $path = Storage::files($this->file_path . '/' . $folder)[0]; } else { @@ -76,8 +75,6 @@ public function previewFile($folder) $path = $this->tmp_file_path . $tmp_file->folder_name . '/' . $tmp_file->file_name; } - // dd($path); - $storagePath = Storage::path($path); if (ob_get_level()) { @@ -85,25 +82,60 @@ public function previewFile($folder) } $fileType = pathinfo($storagePath, PATHINFO_EXTENSION); + $fileType = strtolower((string) $fileType); if (in_array($fileType, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'])) { - $image = Image::make($storagePath); + try { + $image = Image::make($storagePath); - return $image->response($image->mime()); - } else { - $mimeType = mime_content_type($storagePath); + return $image->response($image->mime()); + } catch (\Throwable $th) { + // Fallback for formats that the active image driver cannot decode (e.g. HEIC). + } + } + + $mimeType = (string) mime_content_type($storagePath); + + if (in_array($fileType, ['heic', 'heif']) || in_array($mimeType, ['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'])) { + $jpegBinary = $this->convertHeicToJpeg($storagePath); + + if ($jpegBinary !== null) { + return response($jpegBinary, 200, [ + 'Content-Type' => 'image/jpeg', + 'Content-Disposition' => 'inline; filename="' . pathinfo($storagePath, PATHINFO_FILENAME) . '.jpg"', + ]); + } + } + + return response()->file($storagePath, [ + 'Content-Type' => $mimeType, + 'Content-Disposition' => 'inline; filename="' . basename($storagePath) . '"', + ]); + } - return response()->file($storagePath, [ - 'Content-Type' => $mimeType, - ]); + private function convertHeicToJpeg(string $storagePath): ?string + { + $imagickClass = 'Imagick'; + + if (! class_exists($imagickClass)) { + return null; } - $image = Image::make($storagePath); + try { + $imagick = new $imagickClass(); + $imagick->readImage($storagePath); + $imagick->setImageFormat('jpeg'); + $imagick->setImageCompressionQuality(90); + + $jpegBinary = $imagick->getImagesBlob(); + + $imagick->clear(); + $imagick->destroy(); - return $image - ->response($image->mime()); - // ->resize(300, 200) - // ->response('jpg', 70); + return $jpegBinary; + } catch (\Throwable $th) { + return null; + } } public function persistFile(TemporaryFilepond $temp_filepond, Model $model, $role = null, $locale = null) @@ -343,7 +375,7 @@ public function getStoragePath($uuid) $tmp_file = TemporaryFilepond::where('folder_name', $uuid)->first(); if ($tmp_file) { - $path = $this->tmp_file_path . $tmp_file->folder_name . '/' . $tmp_file->file_name; + $path = $this->tmp_file_path . $tmp_file->folder_name; } } @@ -361,7 +393,10 @@ public function getStorageFile($uuid) $path = $this->getStoragePath($uuid); if ($path) { - return Storage::files($path)[0]; + $files = Storage::files($path); + if (count($files) > 0) { + return $files[0]; + } } return null; diff --git a/src/Services/Security/SecurityService.php b/src/Services/Security/SecurityService.php new file mode 100644 index 000000000..d98fa8aaf --- /dev/null +++ b/src/Services/Security/SecurityService.php @@ -0,0 +1,353 @@ + $this->buildCapabilitiesMap() + ); + } + + public function requiredStepUpCapabilities(): array + { + return Cache::remember( + self::CACHE_KEY_REQUIRED_STEP_UP_CAPABILITIES, + self::CACHE_TTL_SECONDS, + fn () => $this->buildRequiredStepUpCapabilities() + ); + } + + public function routeMatchesStepUpCapability(string $capability, ?string $routeName): bool + { + return in_array($capability, $this->stepUpCapabilitiesForRoute($routeName), true); + } + + public function stepUpCapabilitiesForRoute(?string $routeName): array + { + $routeName = is_string($routeName) ? trim($routeName) : ''; + + if ($routeName === '') { + return []; + } + + $map = Cache::remember( + self::CACHE_KEY_ROUTE_STEP_UP_CAPABILITIES, + self::CACHE_TTL_SECONDS, + fn () => $this->buildRouteStepUpCapabilitiesMap() + ); + + return $map[$routeName] ?? []; + } + + public function matchedUserStepUpCapability(?Authenticatable $user, ?string $routeName, ?string $hintCapability = null): ?string + { + if (! $user) { + return null; + } + + $routeCapabilities = $this->stepUpCapabilitiesForRoute($routeName); + + if ($routeCapabilities === []) { + return null; + } + + if (is_string($hintCapability) && $hintCapability !== '' && ! in_array($hintCapability, $routeCapabilities, true)) { + return null; + } + + $userCapabilities = $this->userCapabilities($user); + + if ($userCapabilities === []) { + return null; + } + + if (is_string($hintCapability) && $hintCapability !== '' && in_array($hintCapability, $userCapabilities, true)) { + return $hintCapability; + } + + foreach ($routeCapabilities as $capability) { + if (in_array($capability, $userCapabilities, true)) { + return $capability; + } + } + + return null; + } + + public function userCapabilities(?Authenticatable $user): array + { + if (! $user) { + return []; + } + + if (method_exists($user, 'getAttribute')) { + $capabilities = $user->getAttribute('capabilities'); + + if (is_array($capabilities)) { + return array_values(array_unique(array_filter($capabilities, fn ($capability) => is_string($capability) && $capability !== ''))); + } + } + + $caps = []; + + foreach ($this->getCapabilities() as $role => $roleCaps) { + if ($this->userHasRole($user, $role)) { + $caps = array_merge($caps, (array) $roleCaps); + } + } + + return array_values(array_unique($caps)); + } + + public function userHasCapability(?Authenticatable $user, string $capability): bool + { + if ($user && is_callable([$user, 'hasCapability'])) { + return (bool) $user->hasCapability($capability); + } + + return in_array($capability, $this->userCapabilities($user), true); + } + + public function userRequiresMfa(?Authenticatable $user): bool + { + if (! $this->config('mfa.enabled', false) || ! $user) { + return false; + } + + $requiredRoles = (array) $this->config('mfa.required_roles', []); + + if (method_exists($user, 'existAnyRole') && $user->existAnyRole($requiredRoles)) { + return true; + } + + foreach ($requiredRoles as $requiredRole) { + if ($this->userHasRole($user, (string) $requiredRole)) { + return true; + } + } + + return false; + } + + public function userHasEnabledMfa(?Authenticatable $user): bool + { + if (! $user) { + return false; + } + + $provider = (string) $this->config('mfa.provider', 'email_otp'); + + // Email OTP does not require per-user setup columns. + if ($provider === 'email_otp') { + return true; + } + + // Unknown providers should not lock users out by strict setup checks. + if ($provider !== 'google_totp') { + return true; + } + + return (bool) ($user->google_2fa_enabled ?? false) + && ! empty($user->google_2fa_secret ?? null); + } + + public function canWriteField(?Authenticatable $user, string $field): bool + { + $permission = $this->config("critical_field_permissions.{$field}", null); + + if (! $permission) { + return true; + } + + if (! $user) { + return false; + } + + if (is_callable([$user, 'can'])) { + return (bool) $user->can($permission); + } + + return false; + } + + public function canPromote(?Authenticatable $user): bool + { + if (! $user) { + return false; + } + + $allowedRoles = (array) modularityConfig('cms_promotion.approval.roles', []); + $allowedEmails = (array) modularityConfig('cms_promotion.approval.emails', []); + + foreach ($allowedRoles as $role) { + if ($this->userHasRole($user, (string) $role)) { + return true; + } + } + + return in_array((string) ($user->email ?? ''), $allowedEmails, true); + } + + public function flushPersistentCache(bool $warmup = true): void + { + Cache::forget(self::CACHE_KEY_CAPABILITIES_MAP); + Cache::forget(self::CACHE_KEY_REQUIRED_STEP_UP_CAPABILITIES); + Cache::forget(self::CACHE_KEY_ROUTE_STEP_UP_CAPABILITIES); + + if ($warmup) { + $this->warmupPersistentCache(); + } + } + + public function warmupPersistentCache(): void + { + $this->getCapabilities(); + $this->requiredStepUpCapabilities(); + Cache::remember( + self::CACHE_KEY_ROUTE_STEP_UP_CAPABILITIES, + self::CACHE_TTL_SECONDS, + fn () => $this->buildRouteStepUpCapabilitiesMap() + ); + } + + private function buildCapabilitiesMap(): array + { + $query = $this->capabilityQuery(); + + if (! $query) { + return []; + } + + $map = []; + + foreach ($query->where('published', true)->with('roles:id,name')->get(['id', 'name']) as $capability) { + $name = (string) ($capability->name ?? ''); + if ($name === '') { + continue; + } + + foreach ($capability->roles ?? [] as $role) { + $role = (string) ($role->name ?? ''); + if ($role === '') { + continue; + } + + $map[$role] ??= []; + $map[$role][] = $name; + } + } + + return collect($map) + ->map(fn ($caps) => array_values(array_unique((array) $caps))) + ->toArray(); + } + + private function buildRequiredStepUpCapabilities(): array + { + $query = $this->capabilityQuery(); + + if (! $query) { + return []; + } + + return $query + ->where('published', true) + ->where('requires_step_up', true) + ->pluck('name') + ->filter(fn ($name) => is_string($name) && $name !== '') + ->values() + ->all(); + } + + private function buildRouteStepUpCapabilitiesMap(): array + { + $query = $this->capabilityQuery(); + + if (! $query) { + return []; + } + + $map = []; + + $query + ->where('published', true) + ->where('requires_step_up', true) + ->with(['routes' => fn ($q) => $q->where('is_active', true)]) + ->get(['id', 'name']) + ->each(function ($capability) use (&$map) { + $capabilityName = (string) ($capability->name ?? ''); + + if ($capabilityName === '') { + return; + } + + foreach ($capability->routes ?? [] as $route) { + $routeName = (string) ($route->route_name ?? ''); + + if ($routeName === '') { + continue; + } + + $map[$routeName] ??= []; + $map[$routeName][] = $capabilityName; + } + }); + + return collect($map) + ->map(fn ($capabilities) => array_values(array_unique(array_filter($capabilities, fn ($capability) => is_string($capability) && $capability !== '')))) + ->toArray(); + } + + private function userHasRole(Authenticatable $user, string $role): bool + { + if (is_callable([$user, 'hasRole'])) { + return (bool) $user->hasRole($role); + } + + return false; + } + + private function capabilityQuery(): ?Builder + { + $class = '\Modules\SystemUser\Entities\Capability'; + + if (! class_exists($class)) { + return null; + } + + $model = app()->make($class); + + if (! method_exists($model, 'getTable') || ! method_exists($model, 'newQuery')) { + return null; + } + + if (! Schema::hasTable($model->getTable())) { + return null; + } + + return $model->newQuery(); + } +} diff --git a/src/Services/Security/StepUpService.php b/src/Services/Security/StepUpService.php new file mode 100644 index 000000000..15194c028 --- /dev/null +++ b/src/Services/Security/StepUpService.php @@ -0,0 +1,359 @@ +config('enabled', false); + } + + public function otpField(): string + { + return (string) $this->config('otp_field', modularityConfig('security.mfa.otp_field', 'verify-code')); + } + + public function pageKey(): string + { + return (string) $this->config('page', 'step_up'); + } + + public function challengeRouteName(): string + { + $route = (string) $this->config('challenge_form_route', 'admin.step-up.form'); + + return Route::has($route) ? $route : Route::hasAdmin('dashboard'); + } + + public function verifyRouteName(): string + { + $route = (string) $this->config('verify_route', 'admin.step-up.verify'); + + return Route::has($route) ? $route : Route::hasAdmin('dashboard'); + } + + public function resendRouteName(): string + { + $route = (string) $this->config('resend_route', 'admin.step-up.resend'); + + return Route::has($route) ? $route : $this->challengeRouteName(); + } + + public function challengePayload(?string $capability = null): array + { + return [ + 'title' => __('Verification required'), + 'description' => __('We sent a verification code to your email to confirm this sensitive action.'), + 'verifyUrl' => route($this->verifyRouteName()), + 'resendUrl' => route($this->resendRouteName()), + 'otpField' => $this->otpField(), + 'otpLength' => $this->codeLength(), + 'buttonText' => __('Verify'), + 'resendText' => __('Resend code'), + 'capability' => $capability, + ]; + } + + public function interrupt(Request $request, string $capability): JsonResponse|RedirectResponse + { + $user = $request->user(); + + abort_unless($user instanceof Authenticatable, 403); + + $resolvedUser = User::find($user->getAuthIdentifier()); + abort_unless($resolvedUser instanceof User, 403); + + + $this->storePendingRequest($request, $capability); + $this->createChallenge($request, $resolvedUser, $capability); + + if ($request->expectsJson()) { + return response()->json([ + 'message' => __('Step-up verification required.'), + 'variant' => MessageStage::WARNING, + 'step_up_required' => true, + 'step_up' => $this->challengePayload($capability), + ], 428); + } + + return redirect()->to(route($this->challengeRouteName())); + } + + public function resend(Request $request): JsonResponse|RedirectResponse + { + $user = $this->resolveUserFromSession($request); + + if (! $user) { + return $this->failureResponse($request, __('Your verification session has expired. Please try again.')); + } + + $capability = (string) $request->session()->get($this->capabilitySessionKey(), ''); + $this->createChallenge($request, $user, $capability); + + if ($request->expectsJson()) { + return response()->json([ + 'message' => __('A new verification code has been sent.'), + 'variant' => MessageStage::SUCCESS, + ], 200); + } + + return redirect()->to(route($this->challengeRouteName())) + ->with('status', __('A new verification code has been sent.')); + } + + public function verify(Request $request): JsonResponse|RedirectResponse + { + $user = $this->resolveUserFromSession($request); + + if (! $user) { + return $this->failureResponse($request, __('Your verification session has expired. Please try again.')); + } + + if (! $this->validateOtp($request, $user)) { + return $this->failureResponse($request, __('Your verification code is invalid.')); + } + + $request->session()->put('security_step_up_verified_at', time()); + + if ($request->expectsJson()) { + $this->clearChallengeState($request, keepPendingRequest: true); + + return response()->json([ + 'message' => __('Verification completed. You can continue your action.'), + 'variant' => MessageStage::SUCCESS, + 'step_up_verified' => true, + ], 200); + } + + $pending = $this->pullPendingRequest($request); + $this->clearChallengeState($request, keepPendingRequest: false); + + if (! $pending) { + $returnUrl = (string) $request->session()->pull($this->returnUrlSessionKey(), route(Route::hasAdmin('dashboard'))); + + return redirect()->to($returnUrl); + } + + if (($pending['method'] ?? 'GET') === 'GET') { + return redirect()->to((string) ($pending['full_url'] ?? $pending['url'] ?? route(Route::hasAdmin('dashboard')))); + } + + return response()->view(modularityBaseKey() . '::auth.step-up-replay', [ + 'pendingRequest' => $pending, + 'pageTitle' => __('Continuing your action') . ' | ' . \Unusualify\Modularity\Facades\Modularity::pageTitle(), + 'otpField' => $this->otpField(), + ]); + } + + public function resolveUserFromSession(Request $request): ?User + { + $userId = $request->session()->get($this->userSessionKey()); + + if (! $userId) { + return null; + } + + return User::find($userId); + } + + public function hasActiveChallenge(Request $request): bool + { + return (bool) $request->session()->has($this->userSessionKey()); + } + + private function provider(): string + { + return (string) $this->config('provider', modularityConfig('security.mfa.provider', 'email_otp')); + } + + private function usesEmailOtp(): bool + { + return $this->provider() === 'email_otp'; + } + + private function userSessionKey(): string + { + return (string) $this->config('user_session_key', 'step-up:user:id'); + } + + private function flowSessionKey(): string + { + return (string) $this->config('flow_session_key', 'step-up:flow:key'); + } + + private function capabilitySessionKey(): string + { + return (string) $this->config('capability_session_key', 'step-up:capability:key'); + } + + private function pendingRequestSessionKey(): string + { + return (string) $this->config('pending_request_session_key', 'step-up:pending:request'); + } + + private function returnUrlSessionKey(): string + { + return (string) $this->config('return_url_session_key', 'step-up:return:url'); + } + + private function codeLength(): int + { + return (int) $this->config('email_otp.length', 6); + } + + private function codeExpiryMinutes(): int + { + return (int) $this->config('email_otp.expire_minutes', 10); + } + + private function codeMaxAttempts(): int + { + return (int) $this->config('email_otp.max_attempts', 5); + } + + private function cachePrefix(): string + { + return (string) $this->config('email_otp.cache_prefix', 'step-up:email-otp'); + } + + private function generateCode(): string + { + $length = max(4, min(10, $this->codeLength())); + $max = (10 ** $length) - 1; + + return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + } + + private function createChallenge(Request $request, User $user, ?string $capability = null): void + { + if ($this->usesEmailOtp()) { + $flowKey = $this->cachePrefix() . ':' . (string) Str::uuid(); + $code = $this->generateCode(); + $expiresAt = now()->addMinutes($this->codeExpiryMinutes()); + + Cache::put($flowKey, [ + 'user_id' => $user->id, + 'email' => $user->email, + 'code_hash' => Hash::make($code), + 'attempts' => 0, + 'expires_at' => $expiresAt->toDateTimeString(), + 'capability' => $capability, + ], $expiresAt); + + $request->session()->put($this->flowSessionKey(), $flowKey); + + $user->notify(new StepUpCodeNotification( + code: $code, + expiresAt: $expiresAt, + capability: $capability, + )); + } + + $request->session()->put($this->userSessionKey(), $user->id); + $request->session()->put($this->capabilitySessionKey(), $capability); + $request->session()->put($this->returnUrlSessionKey(), url()->previous()); + } + + private function storePendingRequest(Request $request, ?string $capability = null): void + { + $request->session()->put($this->pendingRequestSessionKey(), [ + 'url' => $request->url(), + 'full_url' => $request->fullUrl(), + 'method' => strtoupper($request->method()), + 'payload' => collect($request->request->all()) + ->except(['_token', '_method']) + ->toArray(), + 'query' => $request->query(), + 'capability' => $capability, + ]); + } + + private function pullPendingRequest(Request $request): ?array + { + $pending = $request->session()->pull($this->pendingRequestSessionKey()); + + return is_array($pending) ? $pending : null; + } + + private function clearChallengeState(Request $request, bool $keepPendingRequest = false): void + { + $flowKey = (string) $request->session()->get($this->flowSessionKey(), ''); + if ($flowKey !== '') { + Cache::forget($flowKey); + } + + $request->session()->forget($this->userSessionKey()); + $request->session()->forget($this->flowSessionKey()); + $request->session()->forget($this->capabilitySessionKey()); + + if (! $keepPendingRequest) { + $request->session()->forget($this->pendingRequestSessionKey()); + } + } + + private function validateOtp(Request $request, User $user): bool + { + if ($this->usesEmailOtp()) { + $flowKey = (string) $request->session()->get($this->flowSessionKey(), ''); + $challenge = $flowKey !== '' ? Cache::get($flowKey) : null; + + if (! is_array($challenge) || (int) ($challenge['user_id'] ?? 0) !== (int) $user->id) { + return false; + } + + if ((int) ($challenge['attempts'] ?? 0) >= $this->codeMaxAttempts()) { + Cache::forget($flowKey); + + return false; + } + + $otp = (string) $request->input($this->otpField(), ''); + $valid = Hash::check($otp, (string) ($challenge['code_hash'] ?? '')); + + if (! $valid) { + $challenge['attempts'] = (int) ($challenge['attempts'] ?? 0) + 1; + Cache::put($flowKey, $challenge, now()->addMinutes($this->codeExpiryMinutes())); + } + + return $valid; + } + + $otp = (string) $request->input($this->otpField(), ''); + + return (new Google2FA)->verifyKey((string) $user->google_2fa_secret, $otp); + } + + private function failureResponse(Request $request, string $message): JsonResponse|RedirectResponse + { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 422); + } + + return redirect()->to(route($this->challengeRouteName())) + ->withErrors(['error' => $message]); + } +} diff --git a/src/Services/SlugInputValidationService.php b/src/Services/SlugInputValidationService.php new file mode 100644 index 000000000..a54d24b8f --- /dev/null +++ b/src/Services/SlugInputValidationService.php @@ -0,0 +1,193 @@ +resolveModelClass($moduleName, $routeName); + + return $this->validateModelSlug($modelClass, $value, $locale, $localeScoped, $excludeId); + } + + /** + * Validate slug for a concrete model class (used by HTTP layer and tests). + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + * @return array{valid: bool, message: ?string, normalized: string} + */ + public function validateModelSlug( + string $modelClass, + string $value, + ?string $locale = null, + bool $localeScoped = true, + ?int $excludeId = null, + ): array { + if (! in_array(HasSlug::class, class_uses_recursive($modelClass), true)) { + return [ + 'valid' => false, + 'message' => __('This entity does not support slug validation.'), + 'normalized' => '', + ]; + } + + $model = new $modelClass; + + $locale = $locale ?? app()->getLocale(); + + $normalized = $this->normalizeSlugForModel($model, $value, $locale); + + if ($normalized === '') { + return [ + 'valid' => false, + 'message' => __('The slug field is required.'), + 'normalized' => '', + ]; + } + + $slugModelClass = $model->getSlugModelClass(); + $foreignKey = $model->getForeignKey(); + + $query = $slugModelClass::query() + ->where('slug', $normalized); + + if ($localeScoped) { + $query->where('locale', $locale); + } + + if ($excludeId !== null) { + $query->where($foreignKey, '!=', $excludeId); + } + + if ($query->exists()) { + return [ + 'valid' => false, + 'message' => __('This slug is already taken.'), + 'normalized' => $normalized, + ]; + } + + return [ + 'valid' => true, + 'message' => null, + 'normalized' => $normalized, + ]; + } + + /** + * @throws InvalidArgumentException + */ + public function resolveModelClass(string $moduleName, string $routeName): string + { + $module = Modularity::find($moduleName); + + if ($module === null) { + throw new InvalidArgumentException(__('The specified module was not found.')); + } + + return $module->getRouteClass($routeName, 'model'); + } + + /** + * Propose a unique slug for admin inputs: normalize like {@see HasSlug}, then validate with {@see validateModelSlug} + * (including uniqueness + optional registry checks in CMS). On conflict, tries {@code base-2}, {@code base-3}, … + * + * @return array{slug: string, normalized: string, suffixed: bool} + */ + public function proposeUniqueSlug( + string $moduleName, + string $routeName, + string $source, + ?string $locale = null, + bool $localeScoped = true, + ?int $excludeId = null, + ): array { + $modelClass = $this->resolveModelClass($moduleName, $routeName); + + return $this->proposeUniqueSlugForModel($modelClass, $source, $locale, $localeScoped, $excludeId); + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + * @return array{slug: string, normalized: string, suffixed: bool} + */ + public function proposeUniqueSlugForModel( + string $modelClass, + string $source, + ?string $locale = null, + bool $localeScoped = true, + ?int $excludeId = null, + ): array { + if (! in_array(HasSlug::class, class_uses_recursive($modelClass), true)) { + throw new InvalidArgumentException(__('This entity does not support slug validation.')); + } + + /** @var \Illuminate\Database\Eloquent\Model $model */ + $model = new $modelClass; + $locale = $locale ?? app()->getLocale(); + $trimmedSource = trim($source); + + if ($trimmedSource === '') { + throw new InvalidArgumentException(__('Enter a title or source text to generate a slug.')); + } + + $base = $this->normalizeSlugForModel($model, $trimmedSource, $locale); + if ($base === '') { + throw new InvalidArgumentException(__('The slug field is required.')); + } + + for ($attempt = 0; $attempt < 500; $attempt++) { + $candidate = $attempt === 0 ? $base : $base . '-' . ($attempt + 1); + $result = $this->validateModelSlug($modelClass, $candidate, $locale, $localeScoped, $excludeId); + if (($result['valid'] ?? false) === true) { + $normalized = (string) ($result['normalized'] ?? $candidate); + + return [ + 'slug' => $normalized, + 'normalized' => $normalized, + 'suffixed' => $attempt > 0, + ]; + } + } + + throw new InvalidArgumentException(__('Could not find an available slug.')); + } + + /** + * Match {@see HasSlug} slug generation for the given locale. + */ + protected function normalizeSlugForModel($model, string $raw, string $locale): string + { + $trimmed = trim($raw); + + if ($trimmed === '') { + return ''; + } + + if (in_array($locale, modularityConfig('slug_utf8_languages', []), true)) { + return $model->getUtf8Slug($trimmed); + } + + return Str::slug($trimmed); + } +} diff --git a/src/Services/ValidationExceptionFactory.php b/src/Services/ValidationExceptionFactory.php new file mode 100644 index 000000000..f94769909 --- /dev/null +++ b/src/Services/ValidationExceptionFactory.php @@ -0,0 +1,18 @@ +variant(); + } +} diff --git a/src/Support/Decomposers/SchemaParser.php b/src/Support/Decomposers/SchemaParser.php index d7d0e5701..392ef1e2c 100755 --- a/src/Support/Decomposers/SchemaParser.php +++ b/src/Support/Decomposers/SchemaParser.php @@ -111,13 +111,26 @@ public function __construct($schema = null, $useDefaults = true, $model = null) if (@trait_exists($modelTrait)) { $this->traits[$key] = get_class_short_name($modelTrait); $this->traitNamespaces[$key] = $modelTrait; - } else { + } else if(@trait_exists("{$this->baseNamespace}\\Entities\\Traits\\{$modelTrait}")) { $this->traits[$key] = $modelTrait; $this->traitNamespaces[$key] = "{$this->baseNamespace}\\Entities\\Traits\\{$modelTrait}"; } - $this->repositoryTraits[$key] = isset($object['repository']) ? $object['repository'] : ''; - $this->repositoryTraitNamespaces[$key] = isset($object['repository']) ? "{$this->baseNamespace}\\Repositories\\Traits\\{$object['repository']}" : ''; + $repositoryTrait = ''; + $repositoryTraitNamespace = ''; + + if(isset($object['repository'])) { + if(@trait_exists($object['repository'])) { + $repositoryTrait = get_class_short_name($object['repository']); + $repositoryTraitNamespace = $object['repository']; + } else if(@trait_exists("{$this->baseNamespace}\\Repositories\\Traits\\{$object['repository']}")) { + $repositoryTrait = $object['repository']; + $repositoryTraitNamespace = "{$this->baseNamespace}\\Repositories\\Traits\\{$object['repository']}"; + } + } + + $this->repositoryTraits[$key] = $repositoryTrait; + $this->repositoryTraitNamespaces[$key] = $repositoryTraitNamespace; if (array_key_exists('implementations', $object)) { $this->interfaces[$key] = Collection::make($object['implementations'])->map(function ($interface) { @@ -131,6 +144,7 @@ public function __construct($schema = null, $useDefaults = true, $model = null) $this->interfaceNamespaces[$key] = []; } } + $this->relationshipKeys[] = 'belongsToMany'; $this->relationshipKeys[] = 'hasOne'; $this->relationshipKeys[] = 'hasMany'; diff --git a/src/Support/Finder.php b/src/Support/Finder.php index c6580bbf5..2df89731f 100755 --- a/src/Support/Finder.php +++ b/src/Support/Finder.php @@ -91,7 +91,7 @@ public function getRouteModel($routeName, $asClass = false) } foreach ($this->getClasses($entityPath) as $_class) { - if (get_class_short_name(App::make($_class)) === $this->getStudlyName($routeName)) { + if ($this->classBasename($_class) === $this->getStudlyName($routeName)) { $class = $_class; break 2; @@ -161,7 +161,7 @@ public function getRouteRepository($routeName, $asClass = false) continue; } foreach ($this->getClasses($path) as $_class) { - if (get_class_short_name(App::make($_class)) === $this->getStudlyName($routeName) . 'Repository') { + if ($this->classBasename($_class) === $this->getStudlyName($routeName) . 'Repository') { $class = $_class; break 2; @@ -255,4 +255,13 @@ public function getModelsWithTrait($trait) return $this->getAllModels() ->filter(fn ($model) => in_array($trait, class_uses_recursive($model)))->values()->toArray(); } + + /** + * Unqualified class name without resolving from the container (avoids + * instantiating unrelated repositories/models while scanning). + */ + private function classBasename(string $class): string + { + return basename(str_replace('\\', '/', $class)); + } } diff --git a/src/Support/ModularityFlashWarnings.php b/src/Support/ModularityFlashWarnings.php new file mode 100644 index 000000000..79f7f1634 --- /dev/null +++ b/src/Support/ModularityFlashWarnings.php @@ -0,0 +1,58 @@ +|string $messages + */ + public static function merge(array|string $messages): void + { + $incoming = self::normalizeIncoming($messages); + if ($incoming === []) { + return; + } + + $current = Session::get(self::SESSION_KEY, []); + $currentList = is_array($current) ? self::normalizeIncoming($current) : []; + + Session::flash(self::SESSION_KEY, array_values(array_unique([...$currentList, ...$incoming]))); + } + + /** + * @param mixed $messages + * @return list + */ + private static function normalizeIncoming(array|string $messages): array + { + if (is_string($messages)) { + $t = trim($messages); + + return $t === '' ? [] : [$t]; + } + + $out = []; + foreach ($messages as $m) { + if ($m === null || $m === '') { + continue; + } + $s = trim(is_scalar($m) ? (string) $m : ''); + if ($s !== '') { + $out[] = $s; + } + } + + return $out; + } +} diff --git a/src/Support/ModularityRoutes.php b/src/Support/ModularityRoutes.php index 2c1518435..6ea4ab86d 100755 --- a/src/Support/ModularityRoutes.php +++ b/src/Support/ModularityRoutes.php @@ -26,11 +26,55 @@ class ModularityRoutes { + private static array $dynamicDefaultMiddlewares = []; + + private static array $dynamicPanelMiddlewares = []; + private array $defaultMiddlewares = [ 'modularity.log', 'modularity.core', ]; + public function addDefaultMiddleware(string $middleware): void + { + $middleware = trim($middleware); + if ($middleware === '') { + return; + } + + self::$dynamicDefaultMiddlewares[] = $middleware; + self::$dynamicDefaultMiddlewares = array_values(array_unique(self::$dynamicDefaultMiddlewares)); + } + + public function addPanelMiddleware(string $middleware): void + { + $middleware = trim($middleware); + if ($middleware === '') { + return; + } + + self::$dynamicPanelMiddlewares[] = $middleware; + self::$dynamicPanelMiddlewares = array_values(array_unique(self::$dynamicPanelMiddlewares)); + } + + public function addDefaultMiddlewares(array $middlewares): void + { + foreach ($middlewares as $middleware) { + if (is_string($middleware)) { + $this->addDefaultMiddleware($middleware); + } + } + } + + public function addPanelMiddlewares(array $middlewares): void + { + foreach ($middlewares as $middleware) { + if (is_string($middleware)) { + $this->addPanelMiddleware($middleware); + } + } + } + public function configureRoutePatterns(): void { if (($patterns = modularityConfig('route_patterns')) != null) { @@ -56,48 +100,52 @@ public function groupOptions(): array public function webMiddlewares(): array { - return [ - ...['web'], - ...$this->defaultMiddlewares, - ]; + return array_values(array_unique([ + 'web', + ...$this->defaultMiddlewares(), + ])); } public function webPanelMiddlewares(): array { - return [ - ...['web.auth'], - ...$this->defaultMiddlewares, - ...['modularity.panel'], - ]; + return array_values(array_unique([ + 'web.auth', + ...$this->defaultMiddlewares(), + ...$this->defaultPanelMiddlewares(), + ])); } public function apiMiddlewares(): array { - return [ - ...['api'], - ...$this->defaultMiddlewares, - ]; + return array_values(array_unique([ + 'api', + ...$this->defaultMiddlewares(), + ])); } public function apiPanelMiddlewares(): array { - return [ - ...['api.auth'], - ...$this->defaultMiddlewares, - ...['modularity.panel'], - ]; + return array_values(array_unique([ + 'api.auth', + ...$this->defaultMiddlewares(), + ...$this->defaultPanelMiddlewares(), + ])); } public function defaultMiddlewares(): array { - return $this->defaultMiddlewares; + return array_values(array_unique([ + ...$this->defaultMiddlewares, + ...self::$dynamicDefaultMiddlewares, + ])); } public function defaultPanelMiddlewares(): array { - return [ + return array_values(array_unique([ 'modularity.panel', - ]; + ...self::$dynamicPanelMiddlewares, + ])); } public function generateRouteMiddlewares(): void diff --git a/src/Support/PublishableMetadata.php b/src/Support/PublishableMetadata.php new file mode 100644 index 000000000..611009279 --- /dev/null +++ b/src/Support/PublishableMetadata.php @@ -0,0 +1,43 @@ +boolean('published')->default(true); + } + + if ($publishDates) { + $table->timestamp('publish_start_date')->nullable(); + $table->timestamp('publish_end_date')->nullable(); + } + } + + /** + * Default Modularity form input definitions (usually appended via {@see PublishableTrait}). + * + * @return list> + */ + public static function defaultFormInputs(): array + { + return [ + ['type' => 'switch', 'name' => 'published', 'label' => 'Published', 'trueValue' => true, 'falseValue' => false, 'isEvent' => true], + ['name' => 'publish_start_date', 'label' => 'Publish from', 'type' => 'date', 'isSecondary' => true], + ['name' => 'publish_end_date', 'label' => 'Publish until', 'type' => 'date', 'isSecondary' => true], + ]; + } +} diff --git a/src/Support/TranslatableMetadata.php b/src/Support/TranslatableMetadata.php new file mode 100644 index 000000000..c2f26c4fb --- /dev/null +++ b/src/Support/TranslatableMetadata.php @@ -0,0 +1,77 @@ + + */ + public const TRANSLATED_ATTRIBUTES = [ + 'seo_title', + 'seo_description', + 'canonical_url', + 'robots_index', + 'robots_follow', + 'sitemap_include', + ]; + + /** + * Casts for a dedicated translation model, if you define one (optional). + * + * @return array + */ + public static function translationCasts(): array + { + return [ + 'robots_index' => 'boolean', + 'robots_follow' => 'boolean', + 'sitemap_include' => 'boolean', + ]; + } + + /** + * Add standard metadata columns to a translations table blueprint. + */ + public static function addColumns(Blueprint $table, bool $withSitemapInclude = true): void + { + $table->string('seo_title')->nullable(); + $table->text('seo_description')->nullable(); + $table->string('canonical_url', 2048)->nullable(); + $table->boolean('robots_index')->default(true); + $table->boolean('robots_follow')->default(true); + + if ($withSitemapInclude) { + $table->boolean('sitemap_include')->default(true); + } + } + + /** + * Default Modularity form input definitions (usually appended via {@see TranslatableMetadataTrait}). + * + * @return list> + */ + public static function defaultFormInputs(): array + { + return [ + ['name' => 'seo_title', 'label' => 'SEO Title', 'type' => 'text', 'translated' => true, 'isSecondary' => true], + ['name' => 'seo_description', 'label' => 'SEO Description', 'type' => 'textarea', 'translated' => true, 'isSecondary' => true], + ['name' => 'canonical_url', 'label' => 'Canonical URL', 'type' => 'text', 'translated' => true, 'isSecondary' => true], + ['name' => 'robots_index', 'label' => 'Robots Index', 'type' => 'switch', 'translated' => true, 'isSecondary' => true], + ['name' => 'robots_follow', 'label' => 'Robots Follow', 'type' => 'switch', 'translated' => true, 'isSecondary' => true], + ['name' => 'sitemap_include', 'label' => 'Include in sitemap', 'type' => 'switch', 'translated' => true, 'isSecondary' => true], + ]; + } +} diff --git a/src/Traits/ManageModuleRoute.php b/src/Traits/ManageModuleRoute.php index e5c573cd3..3e553c12b 100644 --- a/src/Traits/ManageModuleRoute.php +++ b/src/Traits/ManageModuleRoute.php @@ -9,10 +9,26 @@ trait ManageModuleRoute { use Moduleable; - protected ?Module $module = null; + // protected ?Module $module = null; protected ?array $routeConfig = []; + public function isModuleRouteClass() + { + $moduleName = $this->getModuleName(); + $routeName = $this->getRouteName(); + + if (! $moduleName || ! $routeName) { + return false; + } + + if (! Modularity::find($moduleName)?->hasRoute($routeName)) { + return false; + } + + return true; + } + /** * @deprecated use Moduleable::getModuleName() instead * @@ -38,13 +54,7 @@ public function routeName() */ public function getModule() { - if ($this->module) { - return $this->module; - } - - $this->module = Modularity::find($this->getModuleName()); - - return $this->module; + return Modularity::find($this->getModuleName()); } /** diff --git a/src/Traits/ManageTraits.php b/src/Traits/ManageTraits.php index 06450d588..74b11ea2d 100755 --- a/src/Traits/ManageTraits.php +++ b/src/Traits/ManageTraits.php @@ -3,9 +3,7 @@ namespace Unusualify\Modularity\Traits; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Hash; -use Unusualify\Modularity\Facades\ModularityFinder; trait ManageTraits { @@ -121,9 +119,11 @@ public function chunkInputs($schema = null, $all = false, $noGroupChunk = false) public function model() { - $routeName = $this->getRouteName(); + if (! $this->isModuleRouteClass()) { + return null; + } - return ($routeName && $repositoryClass = ModularityFinder::getRouteRepository($routeName)) ? App::make($repositoryClass)?->getModel() : null; + return $this->getModule()?->getModel($this->getRouteName()) ?? null; } public function prepareFieldsBeforeSaveManageTraits($object, $fields) diff --git a/src/Translation/Translator.php b/src/Translation/Translator.php index b1c5d364c..c4c3b89a6 100644 --- a/src/Translation/Translator.php +++ b/src/Translation/Translator.php @@ -16,27 +16,11 @@ class Translator extends IlluminateTranslator public function getTranslations() { - $locale = 'tr'; $group = '*'; - $namespace = '*'; - $lines = $this->loader->load($locale, $group, $namespace); - $groups = $this->loader->getGroups(); - // dd( - // $this, - // $this->loader->namespaces(), - // $this->loader->jsonPaths(), - // $this->loader->getGroups(), - // // $this->loader->load($locale, 'validation', '*'), - // $this->localeArray($locale), - // getLocales(), - // get_class_methods($this), - // ); - $translations = []; foreach (getLocales() as $locale) { - $group = '*'; $translation = $this->loader->load($locale, '*', '*'); foreach ($groups as $group) { diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php new file mode 100644 index 000000000..4306b2d7c --- /dev/null +++ b/src/Validation/Validator.php @@ -0,0 +1,39 @@ + $parameters + */ + public function makeReplacements($message, $attribute, $rule, $parameters) + { + $message = $this->normalizeCurlyValidationPlaceholdersToColons((string) $message); + + return parent::makeReplacements($message, $attribute, $rule, $parameters); + } + + /** + * Convert {min}, {attribute}, {first-index}, etc. to Laravel's :min, :attribute, :first-index. + */ + protected function normalizeCurlyValidationPlaceholdersToColons(string $message): string + { + return (string) preg_replace_callback( + '/\{([^}]+)\}/', + static fn (array $matches) => ':'.$matches[1], + $message + ); + } +} diff --git a/tests/Docs/DocsAuditTest.php b/tests/Docs/DocsAuditTest.php new file mode 100644 index 000000000..52763e8a6 --- /dev/null +++ b/tests/Docs/DocsAuditTest.php @@ -0,0 +1,157 @@ +packageRoot = realpath(__DIR__ . '/../..'); + } + + /** @test */ + public function all_tracked_source_files_have_documentation(): void + { + $missing = $this->collectMissingDocs(); + + if (empty($missing)) { + $this->assertTrue(true); + + return; + } + + $message = "The following source files are missing documentation:\n\n"; + + foreach ($missing as $section => $files) { + $message .= " [{$section}]\n"; + foreach ($files as $file) { + $message .= " - {$file['class']} ({$file['source']}) → expected: {$file['expected']}\n"; + } + $message .= "\n"; + } + + $message .= 'Create the missing .md files or add exclude rules in DocsAuditCommand::sections().'; + + $this->fail($message); + } + + /** @test */ + public function docs_audit_command_exits_zero_when_all_documented(): void + { + $missing = $this->collectMissingDocs(); + + if (! empty($missing)) { + $this->markTestSkipped('Skipped because there are currently undocumented files — fix those first.'); + } + + $this->artisan('modularity:docs:audit', ['--fail-on-missing' => true]) + ->assertExitCode(0); + } + + /** @test */ + public function docs_audit_command_runs_without_errors(): void + { + $this->artisan('modularity:docs:audit') + ->assertExitCode(0); + } + + /** + * Collect all missing docs across every tracked section. + * + * @return array> + */ + private function collectMissingDocs(): array + { + $allMissing = []; + + foreach (DocsAuditCommand::sections() as $section) { + $sourceDir = $this->packageRoot . '/' . $section['source']; + $docsDir = $this->packageRoot . '/' . $section['docs']; + + if (! is_dir($sourceDir)) { + continue; + } + + $sourceFiles = $this->scanSourceFiles($sourceDir, $section); + $docSlugs = is_dir($docsDir) ? $this->scanDocSlugs($docsDir) : []; + + foreach ($sourceFiles as $relPath => $className) { + $expectedSlug = Str::kebab($className); + + if (! isset($docSlugs[$expectedSlug])) { + $allMissing[$section['label']][] = [ + 'class' => $className, + 'source' => $section['source'] . '/' . $relPath, + 'expected' => $expectedSlug . '.md', + ]; + } + } + } + + return $allMissing; + } + + private function scanSourceFiles(string $dir, array $section): array + { + $recursive = $section['recursive'] ?? false; + $excludeDirs = $section['exclude_dirs'] ?? []; + + $finder = (new Finder) + ->files() + ->name('*.php') + ->in($dir); + + if (! $recursive) { + $finder->depth('== 0'); + } + + foreach ($excludeDirs as $exclude) { + $finder->notPath($exclude); + } + + $files = []; + + foreach ($finder as $file) { + $files[$file->getRelativePathname()] = $file->getBasename('.php'); + } + + return $files; + } + + private function scanDocSlugs(string $dir): array + { + $finder = (new Finder) + ->files() + ->name('*.md') + ->notName('index.md') + ->in($dir); + + $slugs = []; + + foreach ($finder as $file) { + $slugs[$file->getBasename('.md')] = true; + } + + return $slugs; + } +} diff --git a/tests/Helpers/MigrationHelpersTest.php b/tests/Helpers/MigrationHelpersTest.php index 69f09fbef..0d8878ef2 100644 --- a/tests/Helpers/MigrationHelpersTest.php +++ b/tests/Helpers/MigrationHelpersTest.php @@ -30,7 +30,7 @@ public function check_default_fields_of_extra_table_creation() $columns = Schema::getColumns('product_extras'); $publishedColumn = collect($columns)->firstWhere('name', 'published'); // $this->assertEquals(false, $publishedColumn['default']); - $this->assertEquals("'0'", $publishedColumn['default']); + $this->assertEquals("'1'", $publishedColumn['default']); } /** @@ -516,20 +516,25 @@ public function it_creates_revisions_table_with_foreign_keys() ->getDoctrineSchemaManager() ->listTableForeignKeys('product_revisions'); - $this->assertCount(2, $foreignKeys); + $this->assertCount(3, $foreignKeys); // Sort for consistent testing usort($foreignKeys, function ($a, $b) { return strcmp($a->getLocalColumns()[0], $b->getLocalColumns()[0]); }); + // Check approved_by foreign key + $this->assertEquals('approved_by', $foreignKeys[0]->getLocalColumns()[0]); + $this->assertEquals('um_users', $foreignKeys[0]->getForeignTableName()); + $this->assertEquals('id', $foreignKeys[0]->getForeignColumns()[0]); + // Check product_id foreign key - $this->assertEquals('product_id', $foreignKeys[0]->getLocalColumns()[0]); - $this->assertEquals('products', $foreignKeys[0]->getForeignTableName()); + $this->assertEquals('product_id', $foreignKeys[1]->getLocalColumns()[0]); + $this->assertEquals('products', $foreignKeys[1]->getForeignTableName()); // Check user_id foreign key - $this->assertEquals('user_id', $foreignKeys[1]->getLocalColumns()[0]); - $this->assertEquals('um_users', $foreignKeys[1]->getForeignTableName()); + $this->assertEquals('user_id', $foreignKeys[2]->getLocalColumns()[0]); + $this->assertEquals('um_users', $foreignKeys[2]->getForeignTableName()); } /** diff --git a/tests/Http/Controllers/Auth/LoginControllerTest.php b/tests/Http/Controllers/Auth/LoginControllerTest.php index 76d44fb66..cb37a0f21 100644 --- a/tests/Http/Controllers/Auth/LoginControllerTest.php +++ b/tests/Http/Controllers/Auth/LoginControllerTest.php @@ -102,8 +102,10 @@ public function it_returns_json_on_failed_login_when_requesting_json(): void $response = $method->invoke($this->controller, $request); $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(422, $response->getStatusCode()); $data = json_decode($response->getContent(), true); - $this->assertArrayHasKey('email', $data); + $this->assertArrayHasKey('errors', $data); + $this->assertArrayHasKey('email', $data['errors']); $this->assertArrayHasKey('message', $data); $this->assertArrayHasKey('variant', $data); } @@ -155,6 +157,9 @@ public function it_returns_json_response_when_authenticated_without_2fa(): void /** @test */ public function it_redirects_to_2fa_form_when_user_has_2fa_enabled(): void { + config()->set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'google_totp'); + $user = (object) [ 'id' => 1, 'google_2fa_secret' => 'secret', @@ -179,6 +184,9 @@ public function it_redirects_to_2fa_form_when_user_has_2fa_enabled(): void /** @test */ public function it_returns_json_with_redirector_when_authenticated_with_2fa_and_requesting_json(): void { + config()->set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'google_totp'); + $user = (object) [ 'id' => 1, 'google_2fa_secret' => 'secret', diff --git a/tests/Hydrates/SlugHydrateTest.php b/tests/Hydrates/SlugHydrateTest.php new file mode 100644 index 000000000..0ee39519b --- /dev/null +++ b/tests/Hydrates/SlugHydrateTest.php @@ -0,0 +1,20 @@ +render(); + + $this->assertEquals('input-slug', $result['type']); + } +} diff --git a/tests/Models/AbstractModelTest.php b/tests/Models/AbstractModelTest.php index d01ced0ac..7aeac9558 100644 --- a/tests/Models/AbstractModelTest.php +++ b/tests/Models/AbstractModelTest.php @@ -91,7 +91,6 @@ public function test_model_has_required_traits() { $traits = class_uses_recursive($this->model); - $this->assertContains('Unusualify\Modularity\Entities\Traits\HasPresenter', $traits); $this->assertContains('Unusualify\Modularity\Entities\Traits\IsTranslatable', $traits); $this->assertContains('Unusualify\Modularity\Entities\Traits\Core\ModelHelpers', $traits); $this->assertContains('Illuminate\Database\Eloquent\SoftDeletes', $traits); diff --git a/tests/Models/Enums/PermissionTest.php b/tests/Models/Enums/PermissionTest.php index 21dc6805d..da4933e8c 100644 --- a/tests/Models/Enums/PermissionTest.php +++ b/tests/Models/Enums/PermissionTest.php @@ -22,6 +22,9 @@ public function test_enum_cases() 'BULKDELETE' => 'bulkDelete', 'BULKFORCEDELETE' => 'bulkForceDelete', 'BULKRESTORE' => 'bulkRestore', + 'REVISION_APPROVE' => 'revisionApprove', + 'REVISION_REJECT' => 'revisionReject', + 'REVISION_RESTORE' => 'revisionRestore', 'ACTIVITY' => 'activity', 'SHOW' => 'show', ]; @@ -35,13 +38,13 @@ public function test_enum_cases() public function test_all_cases_exist() { $cases = Permission::cases(); - $this->assertCount(14, $cases); + $this->assertCount(17, $cases); $caseValues = array_map(fn ($case) => $case->value, $cases); $expectedValues = [ 'create', 'view', 'edit', 'delete', 'forceDelete', 'restore', 'duplicate', 'reorder', 'bulk', 'bulkDelete', 'bulkForceDelete', - 'bulkRestore', 'activity', 'show', + 'bulkRestore', 'activity', 'show', 'revisionApprove', 'revisionReject', 'revisionRestore', ]; foreach ($expectedValues as $value) { diff --git a/tests/Models/RevisionTest.php b/tests/Models/RevisionTest.php new file mode 100644 index 000000000..e325736fc --- /dev/null +++ b/tests/Models/RevisionTest.php @@ -0,0 +1,160 @@ +id(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('source_id')->nullable(); + $table->unsignedBigInteger('article_id')->nullable(); + $table->text('payload')->nullable(); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + Schema::dropIfExists('test_concrete_revisions'); + parent::tearDown(); + } + + public function test_timestamps_are_enabled(): void + { + $revision = new TestConcreteRevision; + + $this->assertTrue($revision->timestamps); + } + + public function test_fillable_contains_payload_user_id_and_source_id(): void + { + $revision = new TestConcreteRevision; + + $this->assertContains('payload', $revision->getFillable()); + $this->assertContains('user_id', $revision->getFillable()); + $this->assertContains('source_id', $revision->getFillable()); + } + + public function test_constructor_does_not_auto_append_foreign_key_when_four_fillable_items(): void + { + // TestConcreteRevision explicitly declares 4 fillable items, so no auto-append + $revision = new TestConcreteRevision; + + $this->assertCount(4, $revision->getFillable()); + $this->assertContains('article_id', $revision->getFillable()); + } + + public function test_constructor_auto_appends_foreign_key_from_class_name_when_three_fillable_items(): void + { + // When $fillable has exactly 3 items, the constructor appends a derived foreign key + $revision = new TestThreeItemFillableRevision; + + $fillable = $revision->getFillable(); + $this->assertCount(4, $fillable); + } + + public function test_get_by_user_attribute_returns_system_when_no_user_associated(): void + { + $revision = TestConcreteRevision::create([ + 'payload' => json_encode(['title' => 'Test']), + 'user_id' => null, + ]); + + // Accessor is defined as getByUserAttribute → accessed as $model->by_user + $this->assertEquals('System', $revision->by_user); + } + + public function test_get_by_user_attribute_returns_user_name(): void + { + $userId = DB::table('um_users')->insertGetId([ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $revision = TestConcreteRevision::create([ + 'payload' => json_encode(['title' => 'Test']), + 'user_id' => $userId, + ]); + + // Reload to trigger eager loading of user + $revision = TestConcreteRevision::find($revision->id); + + $this->assertEquals('Jane Doe', $revision->by_user); + } + + public function test_is_draft_returns_true_when_cms_save_type_starts_with_draft_revision(): void + { + $revision = new TestConcreteRevision; + $revision->payload = json_encode(['cmsSaveType' => 'draft-revision-auto']); + + $this->assertTrue($revision->isDraft()); + } + + public function test_is_draft_returns_true_for_plain_draft_revision_value(): void + { + $revision = new TestConcreteRevision; + $revision->payload = json_encode(['cmsSaveType' => 'draft-revision']); + + $this->assertTrue($revision->isDraft()); + } + + public function test_is_draft_returns_false_when_cms_save_type_is_published(): void + { + $revision = new TestConcreteRevision; + $revision->payload = json_encode(['cmsSaveType' => 'published']); + + $this->assertFalse($revision->isDraft()); + } + + public function test_is_draft_returns_false_when_cms_save_type_is_absent(): void + { + $revision = new TestConcreteRevision; + $revision->payload = json_encode(['title' => 'No save type here']); + + $this->assertFalse($revision->isDraft()); + } + + public function test_user_relationship_is_eager_loaded_via_with(): void + { + $reflection = new \ReflectionClass(TestConcreteRevision::class); + $property = $reflection->getProperty('with'); + $property->setAccessible(true); + + $with = $property->getValue(new TestConcreteRevision); + + $this->assertContains('user', $with); + } +} + +class TestConcreteRevision extends Revision +{ + protected $table = 'test_concrete_revisions'; + + // 4 items: constructor skips auto-append + protected $fillable = ['payload', 'user_id', 'source_id', 'article_id']; +} + +class TestThreeItemFillableRevision extends Revision +{ + protected $table = 'test_concrete_revisions'; + + // Exactly 3 items: constructor will auto-append a foreign key + protected $fillable = ['payload', 'user_id', 'source_id']; +} diff --git a/tests/Models/Traits/HasPresenterTest.php b/tests/Models/Traits/HasPresenterTest.php index efeae9ba6..7eebbc95e 100644 --- a/tests/Models/Traits/HasPresenterTest.php +++ b/tests/Models/Traits/HasPresenterTest.php @@ -3,7 +3,7 @@ namespace Unusualify\Modularity\Tests\Models\Traits; use Illuminate\Database\Eloquent\Model; -use Unusualify\Modularity\Entities\Traits\HasPresenter; +use Unusualify\Modularity\Entities\Traits\Secondary\HasPresenter; use Unusualify\Modularity\Tests\ModelTestCase; class HasPresenterTest extends ModelTestCase @@ -20,7 +20,7 @@ protected function setUp(): void public function test_model_uses_has_presenter_trait() { $traits = class_uses_recursive($this->model); - $this->assertContains('Unusualify\Modularity\Entities\Traits\HasPresenter', $traits); + $this->assertContains('Unusualify\Modularity\Entities\Traits\Secondary\HasPresenter', $traits); } public function test_present_method_throws_exception_when_presenter_not_set() diff --git a/tests/Models/Traits/HasRevisionsTest.php b/tests/Models/Traits/HasRevisionsTest.php new file mode 100644 index 000000000..9b6ef6e5a --- /dev/null +++ b/tests/Models/Traits/HasRevisionsTest.php @@ -0,0 +1,321 @@ +id(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + Schema::create('test_hr_article_revisions', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('test_hr_article_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('source_id')->nullable(); + $table->text('payload')->nullable(); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + Schema::dropIfExists('test_hr_article_revisions'); + Schema::dropIfExists('test_hr_articles'); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Relationship + // ------------------------------------------------------------------------- + + public function test_model_uses_has_revisions_trait(): void + { + $traits = class_uses_recursive(TestHrArticle::class); + + $this->assertContains(HasRevisions::class, $traits); + } + + public function test_revisions_method_returns_has_many_relation(): void + { + $article = TestHrArticle::create(['title' => 'Draft']); + + $this->assertInstanceOf(HasMany::class, $article->revisions()); + } + + public function test_revisions_are_ordered_descending_by_created_at(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + $older = TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V1']), + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + $newer = TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V2']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $revisions = $article->revisions()->get(); + + $this->assertEquals($newer->id, $revisions->first()->id); + $this->assertEquals($older->id, $revisions->last()->id); + } + + // ------------------------------------------------------------------------- + // revisionsArray() + // ------------------------------------------------------------------------- + + public function test_revisions_array_returns_correct_keys(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + $userId = DB::table('um_users')->insertGetId([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'user_id' => $userId, + 'payload' => json_encode(['title' => 'Hello']), + ]); + + $array = $article->revisionsArray(); + + $this->assertIsArray($array); + $this->assertCount(1, $array); + $this->assertArrayHasKey('id', $array[0]); + $this->assertArrayHasKey('author', $array[0]); + $this->assertArrayHasKey('datetime', $array[0]); + $this->assertArrayHasKey('label', $array[0]); + $this->assertArrayHasKey('source_label', $array[0]); + $this->assertArrayHasKey('is_restored', $array[0]); + $this->assertArrayHasKey('source_datetime', $array[0]); + } + + public function test_revisions_array_assigns_version_labels_newest_first(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V1']), + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V2']), + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V3']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $array = $article->revisionsArray(); + + // revisionsArray() returns ordered DESC (newest first), with label V3, V2, V1 + $this->assertEquals('V3', $array[0]['label']); + $this->assertEquals('V2', $array[1]['label']); + $this->assertEquals('V1', $array[2]['label']); + } + + public function test_revisions_array_source_label_is_null_when_no_source_revision(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + 'source_id' => null, + ]); + + $array = $article->revisionsArray(); + + $this->assertNull($array[0]['source_label']); + $this->assertFalse($array[0]['is_restored']); + $this->assertNull($array[0]['source_datetime']); + } + + public function test_revisions_array_source_label_reflects_source_version(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + $v1 = TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + // V2 is a restore of V1, so source_id = V1's id + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Restored from V1']), + 'source_id' => $v1->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $array = $article->revisionsArray(); + + // V2 (index 0, newest) should have source_label 'V1' + $this->assertEquals('V1', $array[0]['source_label']); + $this->assertTrue($array[0]['is_restored']); + $this->assertNotNull($array[0]['source_datetime']); + // V1 (index 1, oldest) should have no source_label + $this->assertNull($array[1]['source_label']); + $this->assertFalse($array[1]['is_restored']); + } + + // ------------------------------------------------------------------------- + // deleteSpecificRevisions() + // ------------------------------------------------------------------------- + + public function test_delete_specific_revisions_removes_oldest_beyond_limit(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + foreach (range(1, 5) as $i) { + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => "V{$i}"]), + 'created_at' => now()->subMinutes(10 - $i), + 'updated_at' => now()->subMinutes(10 - $i), + ]); + } + + $this->assertCount(5, $article->revisions); + + $article->deleteSpecificRevisions(3); + + $this->assertCount(3, $article->fresh()->revisions); + } + + public function test_delete_specific_revisions_respects_model_limit_revisions_property(): void + { + $article = TestHrArticle::create(['title' => 'Limited']); + // Set limitRevisions on the instance — deleteSpecificRevisions reads $this->limitRevisions + $article->limitRevisions = 2; + + foreach (range(1, 4) as $i) { + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => "V{$i}"]), + 'created_at' => now()->subMinutes(10 - $i), + 'updated_at' => now()->subMinutes(10 - $i), + ]); + } + + // Model has limitRevisions = 2, so passing 10 should still trim to 2 + $article->deleteSpecificRevisions(10); + + $this->assertCount(2, $article->fresh()->revisions); + } + + // ------------------------------------------------------------------------- + // scopeMine() + // ------------------------------------------------------------------------- + + public function test_scope_mine_returns_only_models_with_current_user_revisions(): void + { + $userId = DB::table('um_users')->insertGetId([ + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $otherUserId = DB::table('um_users')->insertGetId([ + 'name' => 'Carol', + 'email' => 'carol@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $myArticle = TestHrArticle::create(['title' => 'My Article']); + $otherArticle = TestHrArticle::create(['title' => 'Other Article']); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $myArticle->id, + 'user_id' => $userId, + 'payload' => json_encode(['title' => 'Mine']), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $otherArticle->id, + 'user_id' => $otherUserId, + 'payload' => json_encode(['title' => 'Theirs']), + ]); + + Auth::guard('modularity')->loginUsingId($userId); + + $mine = TestHrArticle::mine()->get(); + + $this->assertCount(1, $mine); + $this->assertEquals($myArticle->id, $mine->first()->id); + } +} + +// --------------------------------------------------------------------------- +// Stubs +// --------------------------------------------------------------------------- + +class TestHrArticle extends Model +{ + use HasRevisions; + + protected $table = 'test_hr_articles'; + + protected $fillable = ['title']; + + protected $revisionModel = TestHrArticleRevision::class; +} + +class TestHrArticleRevision extends Revision +{ + protected $table = 'test_hr_article_revisions'; + + // Empty $fillable + $guarded = [] → all fields are mass-assignable, + // including created_at/updated_at for ordering tests, without triggering + // the parent Revision constructor's foreign-key auto-append (which fires + // only when count($fillable) == 3). + protected $fillable = []; + + protected $guarded = []; +} diff --git a/tests/Repositories/Traits/FilesTraitTest.php b/tests/Repositories/Traits/FilesTraitTest.php index 08c463878..c3c7c1797 100644 --- a/tests/Repositories/Traits/FilesTraitTest.php +++ b/tests/Repositories/Traits/FilesTraitTest.php @@ -129,8 +129,11 @@ public function test_attach_uploaded_file_to_model_via_files_trait_non_translate // Act: update triggers FilesTrait afterSave to sync pivot and attach $this->repository->update($model->id, $fields, $schema); - // Assert: pivot attached - $this->assertTrue($model->fresh()->files->contains('id', $file->id)); + // Assert: pivot matches payload exactly (previous attachment for file-2 removed) + $fresh = $model->fresh(); + $this->assertTrue($fresh->files->contains('id', $file->id)); + $this->assertFalse($fresh->files->contains('id', $file2->id)); + $this->assertCount(1, $fresh->files); } public function test_detach_files_when_payload_omits_role_after_previous_attachment() @@ -335,6 +338,39 @@ public function test_get_form_fields_files_files_trait_returns_empty_array_if_no $this->assertCount(0, $fields['file-2']['en']); $this->assertCount(0, $fields['file-2']['tr']); } + + public function test_hydrate_files_trait_keeps_persisted_files_when_file_role_missing_from_payload() + { + $file = LibraryFile::create([ + 'uuid' => 'uploads/folder/file-persist.pdf', + 'filename' => 'file-persist.pdf', + 'size' => 100, + ]); + + $model = $this->repository->create(['name' => 'Has File']); + $model->files()->attach($file->id, [ + 'role' => 'file-1', + 'locale' => config('app.locale', 'en'), + ]); + + $schema = [ + 'file-1' => [ + 'type' => 'input-file', + 'name' => 'file-1', + 'translated' => false, + ], + ]; + + $this->repository->setColumns($this->repository->chunkInputs($schema)); + + $hydrated = $this->repository->hydrate($model->fresh(), [ + 'name' => 'preview-without-file-inputs', + ]); + + $this->assertTrue($hydrated->relationLoaded('files')); + $this->assertSame(1, $hydrated->files->count()); + $this->assertTrue($hydrated->files->contains('id', $file->id)); + } } class FilesTestModel extends TestModel diff --git a/tests/Repositories/Traits/ImagesTraitTest.php b/tests/Repositories/Traits/ImagesTraitTest.php index ac6474fe1..0bc47601e 100644 --- a/tests/Repositories/Traits/ImagesTraitTest.php +++ b/tests/Repositories/Traits/ImagesTraitTest.php @@ -94,7 +94,10 @@ public function test_attach_uploaded_media_to_model_non_translated_role() $this->repository->update($model->id, $fields, $schema); - $this->assertTrue($model->fresh()->medias->contains('id', $media->id)); + $fresh = $model->fresh(); + $this->assertTrue($fresh->medias->contains('id', $media->id)); + $this->assertFalse($fresh->medias->contains('id', $media2->id)); + $this->assertCount(1, $fresh->medias); } public function test_detach_media_when_payload_omits_role_after_previous_attachment() diff --git a/tests/Repositories/Traits/RevisionsTraitTest.php b/tests/Repositories/Traits/RevisionsTraitTest.php new file mode 100644 index 000000000..4fb25a239 --- /dev/null +++ b/tests/Repositories/Traits/RevisionsTraitTest.php @@ -0,0 +1,439 @@ +id(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + Schema::create('test_rt_article_revisions', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('test_rt_article_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('source_id')->nullable(); + $table->string('status', 32)->default('approved'); + $table->timestamp('approved_at')->nullable(); + $table->unsignedBigInteger('approved_by')->nullable(); + $table->text('payload')->nullable(); + $table->timestamps(); + }); + + $this->repository = new TestRevisionsRepository(new TestRtArticle); + } + + protected function tearDown(): void + { + Schema::dropIfExists('test_rt_article_revisions'); + Schema::dropIfExists('test_rt_articles'); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // createRevisionIfNeeded() + // ------------------------------------------------------------------------- + + public function test_creates_revision_when_payload_changes(): void + { + $article = TestRtArticle::create(['title' => 'Original']); + + $this->repository->createRevisionIfNeeded($article, ['title' => 'Original']); + + $this->assertCount(1, $article->revisions); + } + + public function test_skips_revision_when_payload_is_unchanged(): void + { + $article = TestRtArticle::create(['title' => 'Same']); + + // First call — creates revision + $this->repository->createRevisionIfNeeded($article, ['title' => 'Same']); + $this->assertCount(1, $article->revisions); + + // Second call with identical payload — no new revision + $this->repository->createRevisionIfNeeded($article->fresh(), ['title' => 'Same']); + $this->assertCount(1, $article->fresh()->revisions); + } + + public function test_skips_revision_when_assoc_key_order_differs_but_values_match(): void + { + $article = TestRtArticle::create(['title' => 'X']); + + TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['zebra' => 'z', 'alpha' => 'a', 'nested' => ['m' => 1, 'n' => 2]]), + 'status' => 'approved', + ]); + + $this->repository->createRevisionIfNeeded($article->fresh(), [ + 'alpha' => 'a', + 'zebra' => 'z', + 'nested' => ['n' => 2, 'm' => 1], + ]); + + $this->assertCount(1, $article->fresh()->revisions); + } + + public function test_creates_new_revision_when_payload_differs_from_last(): void + { + $article = TestRtArticle::create(['title' => 'First']); + + $this->repository->createRevisionIfNeeded($article, ['title' => 'First']); + $this->assertCount(1, $article->revisions); + + $this->repository->createRevisionIfNeeded($article->fresh(), ['title' => 'Second']); + $this->assertCount(2, $article->fresh()->revisions); + } + + public function test_skips_revision_when_skip_revision_creation_flag_is_true(): void + { + $article = TestRtArticle::create(['title' => 'Skip me']); + + $this->repository->setSkipRevisionCreation(true); + $this->repository->createRevisionIfNeeded($article, ['title' => 'Skip me']); + + $this->assertCount(0, $article->revisions); + } + + public function test_sets_source_id_when_pending_source_is_provided(): void + { + $article = TestRtArticle::create(['title' => 'Post']); + + $sourceRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Source']), + ]); + + $this->repository->setPendingSourceRevisionId($sourceRevision->id); + $this->repository->createRevisionIfNeeded($article, ['title' => 'Post']); + + $created = $article->revisions()->latest('id')->first(); + $this->assertEquals($sourceRevision->id, $created->source_id); + } + + // ------------------------------------------------------------------------- + // restoreRevision() — regression test for the content-equality bug + // ------------------------------------------------------------------------- + + /** + * Regression test: restoring a revision whose content is identical to the + * most recent revision must still record a new revision entry. + * + * Before the fix, createRevisionIfNeeded() deduplication prevented + * revision creation when content was unchanged (e.g. restoring to the + * same version that is already current). + */ + public function test_restore_always_creates_revision_even_when_content_is_identical_to_latest(): void + { + $article = TestRtArticle::create(['title' => 'Original']); + + // Create an initial revision that matches the current content exactly + $existingRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + ]); + + $this->assertCount(1, $article->revisions); + + // Restore the revision — content is identical to the latest revision. + // Without the fix this would silently skip creation. + $this->repository->restoreRevision($article->id, $existingRevision->id); + + $this->assertCount(2, $article->fresh()->revisions); + } + + public function test_restore_sets_source_id_on_created_revision(): void + { + $article = TestRtArticle::create(['title' => 'Hello']); + + $sourceRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Hello']), + ]); + + $this->repository->restoreRevision($article->id, $sourceRevision->id); + + $newRevision = $article->fresh()->revisions()->latest('id')->first(); + $this->assertEquals($sourceRevision->id, $newRevision->source_id); + } + + public function test_restore_applies_revision_fields_to_model(): void + { + $article = TestRtArticle::create(['title' => 'Current']); + + $targetRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Restored Title']), + ]); + + // Add a newer revision so the target is not the latest + TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Current']), + ]); + + $this->repository->restoreRevision($article->id, $targetRevision->id); + + $this->assertEquals('Restored Title', $article->fresh()->title); + } + + public function test_restore_returns_updated_model(): void + { + $article = TestRtArticle::create(['title' => 'Before']); + + $revision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Before']), + ]); + + $returned = $this->repository->restoreRevision($article->id, $revision->id); + + $this->assertInstanceOf(TestRtArticle::class, $returned); + $this->assertEquals($article->id, $returned->id); + } + + public function test_restore_throws_when_target_revision_is_rejected(): void + { + $article = TestRtArticle::create(['title' => 'Current']); + + $rejected = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Rejected proposal']), + 'status' => 'rejected', + ]); + + $this->expectException(ValidationException::class); + + $this->repository->restoreRevision($article->id, $rejected->id); + } + + public function test_reject_marks_pending_revision_rejected_without_updating_subject(): void + { + $article = TestRtArticleWithWorkflow::create(['title' => 'Live']); + + $pending = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Proposed']), + 'status' => 'pending', + ]); + + $repo = new TestRevisionsRepository(new TestRtArticleWithWorkflow); + $repo->rejectRevision($article->id, $pending->id); + + $this->assertSame('Live', $article->fresh()->title); + $this->assertSame('rejected', $pending->fresh()->status); + } + + public function test_restore_aborts_when_revision_was_created_from_restore(): void + { + $article = TestRtArticle::create(['title' => 'Original']); + + $sourceRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + ]); + + $restoredSnapshot = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + 'source_id' => $sourceRevision->id, + ]); + + try { + $this->repository->restoreRevision($article->id, $restoredSnapshot->id); + $this->fail('Expected HttpException with status 422.'); + } catch (HttpException $e) { + $this->assertSame(422, $e->getStatusCode()); + } + } + + // ------------------------------------------------------------------------- + // getRevisionPayload() + // ------------------------------------------------------------------------- + + public function test_get_revision_payload_returns_decoded_array(): void + { + $article = TestRtArticle::create(['title' => 'Payload test']); + + $revision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Stored', 'body' => 'Content']), + ]); + + $payload = $this->repository->getRevisionPayload($article->id, $revision->id); + + $this->assertEquals(['title' => 'Stored', 'body' => 'Content'], $payload); + } + + public function test_get_revision_payload_returns_empty_array_for_empty_payload(): void + { + $article = TestRtArticle::create(['title' => 'Empty']); + + $revision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => null, + ]); + + $payload = $this->repository->getRevisionPayload($article->id, $revision->id); + + $this->assertEquals([], $payload); + } + + // ------------------------------------------------------------------------- + // afterSaveRevisionsTrait() — invoked via afterSave hook + // ------------------------------------------------------------------------- + + public function test_after_save_hook_creates_revision_on_first_save(): void + { + $article = TestRtArticle::create(['title' => 'New post']); + + $this->repository->afterSave($article, ['title' => 'New post']); + + $this->assertCount(1, $article->revisions); + } + + public function test_after_save_hook_prunes_revisions_beyond_limit(): void + { + $article = TestRtArticle::create(['title' => 'Limited']); + // Set limitRevisions on the instance — afterSaveRevisionsTrait checks $object->limitRevisions + $article->limitRevisions = 2; + + // Manually create 3 existing revisions + foreach (range(1, 3) as $i) { + TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => "V{$i}"]), + 'created_at' => now()->subMinutes(10 - $i), + 'updated_at' => now()->subMinutes(10 - $i), + ]); + } + + // afterSave creates a 4th revision and then prunes down to limitRevisions=2 + $this->repository->afterSave($article, ['title' => 'V4']); + + $this->assertCount(2, $article->fresh()->revisions); + } +} + +// --------------------------------------------------------------------------- +// Stubs +// --------------------------------------------------------------------------- + +class TestRtArticle extends Model +{ + use HasRevisions; + + protected $table = 'test_rt_articles'; + + protected $fillable = ['title']; + + protected $revisionModel = TestRtArticleRevision::class; +} + +/** Workflow-enabled stub for reject/approve guards. */ +class TestRtArticleWithWorkflow extends TestRtArticle +{ + /** + * Keep the same revisions FK as {@see TestRtArticle} (table column is test_rt_article_id). + */ + public function revisions(): HasMany + { + return $this->hasMany($this->getRevisionModel(), 'test_rt_article_id'); + } + + public function latestRevision(): HasOne + { + return $this->hasOne($this->getRevisionModel(), 'test_rt_article_id')->latestOfMany('id'); + } + + protected function revisionWorkflowEnabled(): bool + { + return true; + } + + protected function revisionPermissionPrefix(): ?string + { + return 'test_rt_article'; + } +} + +class TestRtArticleRevision extends Revision +{ + protected $table = 'test_rt_article_revisions'; + + // Empty $fillable + $guarded = [] → all fields are mass-assignable, + // including created_at/updated_at for ordering tests, without triggering + // the parent Revision constructor's foreign-key auto-append (count == 3). + protected $fillable = []; + + protected $guarded = []; +} + +/** + * Minimal repository stub using RevisionsTrait. + * + * The real Repository::update() involves DB transactions and unrelated + * infrastructure. This stub replaces it with a direct fill + save so + * RevisionsTrait logic can be tested in isolation. + */ +class TestRevisionsRepository +{ + use RevisionsTrait; + + public function __construct(public Model $model) {} + + /** + * Simplified update: fill the model and persist, then fire afterSave hooks. + */ + public function update(int $id, array $fields, $schema = null, $options = []): bool + { + $object = $this->model->findOrFail($id); + $object->fill(array_intersect_key($fields, array_flip($object->getFillable()))); + $object->save(); + $this->afterSave($object, $fields); + + return true; + } + + /** + * Fire all afterSave* trait methods (mirrors Repository::afterSave()). + */ + public function afterSave(Model $object, array $fields): void + { + $this->afterSaveRevisionsTrait($object, $fields); + } + + public function setSkipRevisionCreation(bool $value): void + { + $this->skipRevisionCreation = $value; + } + + public function setPendingSourceRevisionId(?int $id): void + { + $this->pendingSourceRevisionId = $id; + } +} diff --git a/tests/Repositories/Traits/SlugsTraitTest.php b/tests/Repositories/Traits/SlugsTraitTest.php index 322ee056c..e3cdd327f 100644 --- a/tests/Repositories/Traits/SlugsTraitTest.php +++ b/tests/Repositories/Traits/SlugsTraitTest.php @@ -90,6 +90,54 @@ public function test_after_save_slugs_trait_updates_slug_with_custom_value(): vo $this->assertEquals('custom-slug', $activeSlug->slug); } + public function test_after_save_slug_payload_active_false_persists_including_string_false(): void + { + config(['translatable.locales' => ['en']]); + + $object = $this->repository->create([ + 'name' => 'Active False Test', + 'is_active' => true, + ]); + + $this->repository->update($object->id, [ + 'slugs' => ['en' => ['slug' => 'active-false-test', 'active' => false]], + ]); + + $object->refresh(); + $row = $object->slugs->where('slug', 'active-false-test')->where('locale', 'en')->first(); + $this->assertNotNull($row); + $this->assertFalse((bool) $row->active); + + $this->repository->update($object->id, [ + 'slugs' => ['en' => ['slug' => 'active-false-test', 'active' => 'false']], + ]); + + $object->refresh(); + $row = $object->slugs->where('slug', 'active-false-test')->where('locale', 'en')->first(); + $this->assertNotNull($row); + $this->assertFalse((bool) $row->active); + } + + public function test_update_without_slug_source_fields_does_not_run_automatic_set_slugs(): void + { + config(['translatable.locales' => ['en']]); + + $object = $this->repository->create([ + 'name' => 'Initial Name', + 'is_active' => true, + ]); + + $countAfterCreate = $object->slugs()->count(); + + $this->repository->update($object->id, [ + 'is_active' => false, + ]); + + $object->refresh(); + $this->assertCount($countAfterCreate, $object->slugs); + $this->assertEquals('initial-name', $object->slugs->where('active', true)->first()->slug); + } + public function test_after_save_slugs_trait_disables_old_slugs_when_new_one_created(): void { config(['translatable.locales' => ['en']]); @@ -162,9 +210,11 @@ public function test_get_form_fields_slugs_trait_returns_active_slug(): void $fields = $this->repository->getFormFields($object); - $this->assertArrayHasKey('translations', $fields); - $this->assertArrayHasKey('slug', $fields['translations']); - $this->assertEquals('form-field-test', $fields['translations']['slug']['en']); + $this->assertArrayHasKey('slugs', $fields); + $this->assertEquals([ + 'slug' => 'form-field-test', + 'active' => true, + ], $fields['slugs']['en']); } public function test_get_form_fields_slugs_trait_returns_slugs_for_multiple_locales(): void @@ -178,9 +228,15 @@ public function test_get_form_fields_slugs_trait_returns_slugs_for_multiple_loca $fields = $this->repository->getFormFields($object); - $this->assertArrayHasKey('slug', $fields['translations']); - $this->assertEquals('multi-locale-test', $fields['translations']['slug']['en']); - $this->assertEquals('multi-locale-test', $fields['translations']['slug']['tr']); + $this->assertArrayHasKey('slugs', $fields); + $this->assertEquals([ + 'slug' => 'multi-locale-test', + 'active' => true, + ], $fields['slugs']['en']); + $this->assertEquals([ + 'slug' => 'multi-locale-test', + 'active' => true, + ], $fields['slugs']['tr']); } public function test_get_slug_parameters_returns_base_slug_params(): void @@ -607,7 +663,7 @@ public function test_reactivate_existing_slug_when_matching(): void $this->assertEquals('original-slug', $activeSlug->slug); } - public function test_get_form_fields_removes_slugs_key(): void + public function test_get_form_fields_populates_slugs_not_slug_source_attributes(): void { config(['translatable.locales' => ['en']]); @@ -618,10 +674,11 @@ public function test_get_form_fields_removes_slugs_key(): void $fields = $this->repository->getFormFields($object); - // The raw 'slugs' key should be removed and replaced with translations.slug - $this->assertArrayNotHasKey('slugs', $fields); - $this->assertArrayHasKey('translations', $fields); - $this->assertArrayHasKey('slug', $fields['translations']); + // URL slug data is under `slugs` (dedicated input), never merged into slug source attributes (e.g. `name`). + $this->assertArrayHasKey('slugs', $fields); + $this->assertArrayHasKey('en', $fields['slugs']); + $this->assertEquals('fields-test', $fields['slugs']['en']['slug'] ?? null); + $this->assertArrayNotHasKey('name', $fields['translations'] ?? []); } } diff --git a/tests/Repositories/Traits/TranslationsTraitTest.php b/tests/Repositories/Traits/TranslationsTraitTest.php index 0bf94f289..bff909bea 100644 --- a/tests/Repositories/Traits/TranslationsTraitTest.php +++ b/tests/Repositories/Traits/TranslationsTraitTest.php @@ -129,10 +129,12 @@ public function test_filter_translations_trait_searches_by_translated_field() $a = $this->repository->create([ 'name' => 'A', 'title' => ['en' => 'Hello', 'tr' => 'Merhaba'], + 'active' => ['en' => true, 'tr' => true], ]); $b = $this->repository->create([ 'name' => 'B', 'title' => ['en' => 'World', 'tr' => 'Dünya'], + 'active' => ['en' => true, 'tr' => true], ]); // Insert translations diff --git a/tests/Services/Cms/CanonicalUrlResolverTest.php b/tests/Services/Cms/CanonicalUrlResolverTest.php new file mode 100644 index 000000000..00cd35d7d --- /dev/null +++ b/tests/Services/Cms/CanonicalUrlResolverTest.php @@ -0,0 +1,24 @@ +set('modularity.cms_routing.canonical_host', 'example.com'); + config()->set('modularity.cms_routing.default_locale', 'en'); + config()->set('modularity.cms_routing.hide_default_locale_segment', false); + config()->set('modularity.cms_routing.redirect_to_canonical', true); + + $service = new CanonicalUrlResolver; + $resolved = $service->resolve('example.com.tr', '/TR/About/', 'tr'); + + $this->assertEquals('/tr/about', $resolved['canonical_path']); + $this->assertTrue($resolved['should_redirect']); + $this->assertEquals('https://example.com/tr/about', $resolved['redirect_to']); + } +} diff --git a/tests/Services/Cms/CmsAdminWarningsTest.php b/tests/Services/Cms/CmsAdminWarningsTest.php new file mode 100644 index 000000000..79b2ca6b0 --- /dev/null +++ b/tests/Services/Cms/CmsAdminWarningsTest.php @@ -0,0 +1,102 @@ +createMock(CanonicalUrlResolverInterface::class); + + $parent = new CmsParentSegmentResolver($canonical); + + return new CmsAdminWarnings(new CmsUrlRouteRegistry($canonical, $parent)); + } + + /** + * Avoid {@see Page} constructor / activity boot in testbench (no auth causer). + */ + private function makePageWithAttributes(array $attributes): Page + { + $ref = new ReflectionClass(Page::class); + /** @var Page $page */ + $page = $ref->newInstanceWithoutConstructor(); + $page->setRawAttributes($attributes); + $page->syncOriginal(); + + return $page; + } + + public function test_gather_includes_schedule_warning_before_start(): void + { + config([ + 'modularity.cms_seo.admin.publish_schedule_warnings' => true, + 'modularity.cms_seo.admin.publish_soft_warnings' => false, + ]); + + $service = $this->makeWarningsService(); + + $page = $this->makePageWithAttributes([ + 'published' => true, + 'publish_start_date' => Carbon::now()->addDay()->toDateTimeString(), + 'publish_end_date' => null, + ]); + + $warnings = $service->gather($page); + + $this->assertNotEmpty($warnings); + $this->assertTrue( + str_contains($warnings[0], 'Not publicly visible yet') || + str_contains(implode(' ', $warnings), 'Not publicly visible yet') + ); + } + + public function test_gather_includes_schedule_warning_after_end(): void + { + config([ + 'modularity.cms_seo.admin.publish_schedule_warnings' => true, + 'modularity.cms_seo.admin.publish_soft_warnings' => false, + ]); + + $service = $this->makeWarningsService(); + + $page = $this->makePageWithAttributes([ + 'published' => true, + 'publish_start_date' => null, + 'publish_end_date' => Carbon::now()->subDay()->toDateTimeString(), + ]); + + $warnings = $service->gather($page); + + $flat = implode(' ', $warnings); + $this->assertStringContainsString('Not publicly visible anymore', $flat); + } + + public function test_schedule_warnings_disabled_emits_none_for_schedule_only(): void + { + config([ + 'modularity.cms_seo.admin.publish_schedule_warnings' => false, + 'modularity.cms_seo.admin.publish_soft_warnings' => false, + ]); + + $service = $this->makeWarningsService(); + + $page = $this->makePageWithAttributes([ + 'published' => true, + 'publish_start_date' => Carbon::now()->addDay()->toDateTimeString(), + ]); + + $warnings = $service->gather($page); + + $this->assertSame([], $warnings); + } +} diff --git a/tests/Services/Cms/CmsFrontPathPublicBrowserPathTest.php b/tests/Services/Cms/CmsFrontPathPublicBrowserPathTest.php new file mode 100644 index 000000000..a28df94a3 --- /dev/null +++ b/tests/Services/Cms/CmsFrontPathPublicBrowserPathTest.php @@ -0,0 +1,64 @@ +app['config']->set('modularity.cms_routing.front_route_prefix', 'cms'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.hide_default_locale_segment', false); + + $canonical = new CanonicalUrlResolver; + $path = CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath('en', '/blog/post', $canonical); + + $this->assertSame('/cms/en/blog/post', $path); + } + + public function test_public_browser_path_hides_default_locale_segment_when_configured(): void + { + $this->app['config']->set('modularity.cms_routing.front_route_prefix', 'cms'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.hide_default_locale_segment', true); + + $canonical = new CanonicalUrlResolver; + $path = CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath('en', '/blog/post', $canonical); + + $this->assertSame('/cms/blog/post', $path); + } + + public function test_public_browser_path_keeps_non_default_locale_when_default_hidden(): void + { + $this->app['config']->set('modularity.cms_routing.front_route_prefix', 'cms'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.hide_default_locale_segment', true); + + $canonical = new CanonicalUrlResolver; + $path = CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath('tr', '/haber/foo', $canonical); + + $this->assertSame('/cms/tr/haber/foo', $path); + } + + public function test_public_browser_path_omits_slugless_fallback_locale_segment_when_toggle_on(): void + { + $this->app['config']->set('modularity.cms_routing.front_route_prefix', 'cms'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'tr'); + $this->app['config']->set('modularity.cms_routing.hide_default_locale_segment', false); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment', true); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment_locale', 'en'); + + $canonical = new CanonicalUrlResolver; + $path = CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath('en', '/pages/test', $canonical); + + $this->assertSame('/cms/pages/test', $path); + + $pathTr = CmsFrontPath::publicBrowserPathForLocaleAndRegistryPath('tr', '/sayfa/x', $canonical); + + $this->assertSame('/cms/tr/sayfa/x', $pathTr); + } +} diff --git a/tests/Services/Cms/CmsFrontPathTest.php b/tests/Services/Cms/CmsFrontPathTest.php new file mode 100644 index 000000000..18aa6b052 --- /dev/null +++ b/tests/Services/Cms/CmsFrontPathTest.php @@ -0,0 +1,35 @@ + 'cms']); + + $canonical = new CanonicalUrlResolver; + $request = Request::create('/cms/tr/foo', 'GET'); + + $inner = CmsFrontPath::innerNormalizedPath($request, $canonical); + + $this->assertSame('/tr/foo', $inner); + } + + public function test_it_returns_slash_when_only_prefix(): void + { + config(['modularity.cms_routing.front_route_prefix' => 'cms']); + + $canonical = new CanonicalUrlResolver; + $request = Request::create('/cms', 'GET'); + + $inner = CmsFrontPath::innerNormalizedPath($request, $canonical); + + $this->assertSame('/', $inner); + } +} diff --git a/tests/Services/Cms/CmsFrontRouteLocalizationBindingTest.php b/tests/Services/Cms/CmsFrontRouteLocalizationBindingTest.php new file mode 100644 index 000000000..1a759e600 --- /dev/null +++ b/tests/Services/Cms/CmsFrontRouteLocalizationBindingTest.php @@ -0,0 +1,33 @@ +assertStringContainsString('pt\-br', $pattern); + // Longest key first so `pt-br` is not shadowed by `pt` in alternation matching. + $this->assertStringStartsWith('pt\-br|', $pattern); + } + + public function test_should_use_locale_prefix_route_group_is_false_when_mode_is_catch_all(): void + { + $this->app['config']->set('modularity.cms_routing.public_front_route_group_mode', 'catch_all'); + + $this->assertFalse(CmsFrontRouteLocalizationBinding::shouldUseLocalePrefixRouteGroup()); + } + + public function test_should_use_locale_prefix_route_group_is_false_when_driver_is_translatable(): void + { + $this->app['config']->set('modularity.cms_routing.public_front_route_group_mode', 'locale_param'); + $this->app['config']->set('modularity.cms_routing.localization_driver', 'translatable'); + + $this->assertFalse(CmsFrontRouteLocalizationBinding::shouldUseLocalePrefixRouteGroup()); + } +} diff --git a/tests/Services/Cms/CmsFrontRouteRegistrarCatchAllPathPatternTest.php b/tests/Services/Cms/CmsFrontRouteRegistrarCatchAllPathPatternTest.php new file mode 100644 index 000000000..a92c7dd91 --- /dev/null +++ b/tests/Services/Cms/CmsFrontRouteRegistrarCatchAllPathPatternTest.php @@ -0,0 +1,47 @@ +app['config']->set('modularity.cms_routing.signed_preview.enabled', true); + $this->app['config']->set('modularity.cms_routing.signed_preview.path_prefix', 'cms/preview'); + + $pattern = self::reflectCatchAllPattern(); + $re = '#' . str_replace('#', '\\#', $pattern) . '#'; + + $this->assertMatchesRegularExpression($re, 'pages/about'); + $this->assertMatchesRegularExpression($re, ''); + $this->assertDoesNotMatchRegularExpression($re, 'cms/preview/Cms/Page/1/tr'); + $this->assertDoesNotMatchRegularExpression($re, 'cms/preview'); + $this->assertMatchesRegularExpression($re, 'cms/previewx/other'); + } + + public function test_extra_exclude_prefix_from_config_blocks_path(): void + { + $this->app['config']->set('modularity.cms_routing.signed_preview.enabled', false); + $this->app['config']->set('modularity.cms_routing.public_front_catch_all_exclude_path_prefixes', ['internal/widget']); + + $pattern = self::reflectCatchAllPattern(); + $re = '#' . str_replace('#', '\\#', $pattern) . '#'; + + $this->assertMatchesRegularExpression($re, 'foo'); + $this->assertDoesNotMatchRegularExpression($re, 'internal/widget/run'); + $this->assertDoesNotMatchRegularExpression($re, 'internal/widget'); + } + + /** @return non-empty-string */ + private static function reflectCatchAllPattern(): string + { + $m = new ReflectionMethod(CmsFrontRouteRegistrar::class, 'catchAllPathParameterPattern'); + $m->setAccessible(true); + + return (string) $m->invoke(null); + } +} diff --git a/tests/Services/Cms/CmsFrontRouteRegistrarDomainTest.php b/tests/Services/Cms/CmsFrontRouteRegistrarDomainTest.php new file mode 100644 index 000000000..d5049edbc --- /dev/null +++ b/tests/Services/Cms/CmsFrontRouteRegistrarDomainTest.php @@ -0,0 +1,67 @@ +app['config']->set('app.url', 'http://frontend.b2press.test'); + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', null); + $this->app['config']->set('modularity.cms_routing.public_front_routes_allow_any_host', false); + $this->app['config']->set('modularity.cms_routing.bind_public_routes_to_app_url_host', null); + + $this->assertSame('frontend.b2press.test', CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain()); + } + + public function test_resolve_public_front_route_domain_legacy_bind_false_maps_to_allow_any_host(): void + { + $this->app['config']->set('app.url', 'http://frontend.b2press.test'); + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', null); + $this->app['config']->set('modularity.cms_routing.public_front_routes_allow_any_host', false); + $this->app['config']->set('modularity.cms_routing.bind_public_routes_to_app_url_host', false); + + $this->assertNull(CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain()); + } + + public function test_resolve_public_front_route_domain_legacy_bind_true_uses_app_url_host(): void + { + $this->app['config']->set('app.url', 'http://frontend.b2press.test'); + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', null); + $this->app['config']->set('modularity.cms_routing.public_front_routes_allow_any_host', false); + $this->app['config']->set('modularity.cms_routing.bind_public_routes_to_app_url_host', true); + + $this->assertSame('frontend.b2press.test', CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain()); + } + + public function test_resolve_public_front_route_domain_allow_any_host_true_overrides_explicit_bind(): void + { + $this->app['config']->set('app.url', 'http://frontend.b2press.test'); + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', null); + $this->app['config']->set('modularity.cms_routing.public_front_routes_allow_any_host', true); + $this->app['config']->set('modularity.cms_routing.bind_public_routes_to_app_url_host', true); + + $this->assertNull(CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain()); + } + + public function test_resolve_public_front_route_domain_respects_explicit_config(): void + { + $this->app['config']->set('app.url', 'http://ignored.example.test'); + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', 'cms.example.test'); + + $this->assertSame('cms.example.test', CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain()); + } + + public function test_resolve_public_front_route_domain_returns_null_when_app_url_has_no_host(): void + { + $this->app['config']->set('app.url', ''); + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', null); + $this->app['config']->set('modularity.cms_routing.public_front_routes_allow_any_host', false); + $this->app['config']->set('modularity.cms_routing.bind_public_routes_to_app_url_host', null); + + $this->assertNull(CmsFrontRouteRegistrar::resolvePublicFrontRouteDomain()); + } +} diff --git a/tests/Services/Cms/CmsLocalizationAdapterTest.php b/tests/Services/Cms/CmsLocalizationAdapterTest.php new file mode 100644 index 000000000..4c35a731f --- /dev/null +++ b/tests/Services/Cms/CmsLocalizationAdapterTest.php @@ -0,0 +1,71 @@ +app['config']->set('modularity.cms_routing.path_segment_locales', ['de', 'en']); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + + $adapter = new TranslatableCmsLocalizationAdapter(new CanonicalUrlResolver); + + $this->assertEqualsCanonicalizing(['de', 'en'], $adapter->pathSegmentLocales()); + $this->assertSame('en', $adapter->defaultLocale()); + $this->assertSame('translatable', $adapter->driver()); + } + + public function test_delegating_merges_path_segment_overrides(): void + { + $this->app['config']->set('modularity.cms_routing.path_segment_locales', ['de', 'en']); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + + $inner = new TranslatableCmsLocalizationAdapter(new CanonicalUrlResolver); + $overrides = new class implements CmsLocalizationOverrideProviderInterface + { + public function pathSegmentLocales(): ?array + { + return ['xx', 'yy']; + } + + public function defaultLocale(): ?string + { + return null; + } + + public function hideDefaultLocaleInUrl(): ?bool + { + return null; + } + + public function supportedLocalesMeta(): ?array + { + return null; + } + }; + + $delegating = new DelegatingCmsLocalizationAdapter($inner, $overrides); + + $this->assertSame(['xx', 'yy'], $delegating->pathSegmentLocales()); + $this->assertSame('en', $delegating->defaultLocale()); + } + + public function test_cms_localization_contract_is_registered_when_cms_enabled(): void + { + $this->app->register(\Modules\Cms\Providers\CmsServiceProvider::class); + $this->app['config']->set('modularity.cms_features.enabled', true); + $this->app['config']->set('modularity.cms_routing.localization_driver', 'translatable'); + $this->app['config']->set('modularity.cms_routing.path_segment_locales', ['en']); + + $this->assertInstanceOf(CmsLocalizationContract::class, $this->app->make(CmsLocalizationContract::class)); + $this->assertSame('translatable', $this->app->make(CmsLocalizationContract::class)->driver()); + } +} diff --git a/tests/Services/Cms/CmsParentSegmentResolverFallbackTest.php b/tests/Services/Cms/CmsParentSegmentResolverFallbackTest.php new file mode 100644 index 000000000..36b8425d7 --- /dev/null +++ b/tests/Services/Cms/CmsParentSegmentResolverFallbackTest.php @@ -0,0 +1,120 @@ +id(); + $table->string('target_model_class'); + $table->string('locale', 24)->nullable(); + $table->string('normalized_prefix', 2048)->nullable(); + $table->string('admin_label', 191)->nullable(); + $table->boolean('enabled')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + }); + + $this->app['config']->set('modularity.cms_parent_segments.enabled', true); + $this->app->singleton(CanonicalUrlResolverInterface::class, CanonicalUrlResolver::class); + } + + public function test_fallback_uses_enabled_default_locale_prefix_when_locale_binding_disabled(): void + { + $this->app['config']->set('translatable.locales', ['tr', 'en']); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'admin_label' => 'pages-en', + 'enabled' => true, + 'sort_order' => 10, + ]); + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'tr', + 'normalized_prefix' => 'sayfalar', + 'admin_label' => 'pages-tr', + 'enabled' => false, + 'sort_order' => 11, + ]); + + $resolver = new CmsParentSegmentResolver(app(CanonicalUrlResolverInterface::class)); + + $this->assertSame('/pages/test', $resolver->joinPublicLeafPath(Page::class, 'tr', 'test')); + $map = $resolver->normalizedPrefixesMapForTargetClass(Page::class); + $this->assertSame('/pages', $map['tr'] ?? null); + $this->assertSame('/pages', $map['en'] ?? null); + } + + public function test_prefers_own_locale_binding_when_enabled(): void + { + $this->app['config']->set('translatable.locales', ['tr', 'en']); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'admin_label' => 'pages-en', + 'enabled' => true, + 'sort_order' => 10, + ]); + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'tr', + 'normalized_prefix' => 'sayfalar', + 'admin_label' => 'pages-tr', + 'enabled' => true, + 'sort_order' => 11, + ]); + + $resolver = new CmsParentSegmentResolver(app(CanonicalUrlResolverInterface::class)); + + $this->assertSame('/sayfalar/test', $resolver->joinPublicLeafPath(Page::class, 'tr', 'test')); + $map = $resolver->normalizedPrefixesMapForTargetClass(Page::class); + $this->assertSame('/sayfalar', $map['tr'] ?? null); + } + + public function test_no_prefix_when_no_enabled_binding_anywhere(): void + { + $this->app['config']->set('translatable.locales', ['tr', 'en']); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'enabled' => false, + 'sort_order' => 1, + ]); + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'tr', + 'normalized_prefix' => 'sayfalar', + 'enabled' => false, + 'sort_order' => 2, + ]); + + $resolver = new CmsParentSegmentResolver(app(CanonicalUrlResolverInterface::class)); + + $this->assertSame('/test', $resolver->joinPublicLeafPath(Page::class, 'tr', 'test')); + } +} diff --git a/tests/Services/Cms/CmsParentSegmentResolverTest.php b/tests/Services/Cms/CmsParentSegmentResolverTest.php new file mode 100644 index 000000000..af7126435 --- /dev/null +++ b/tests/Services/Cms/CmsParentSegmentResolverTest.php @@ -0,0 +1,100 @@ +dropParentSegmentTables(); + + parent::tearDown(); + } + + public function test_join_falls_back_to_leaf_when_tables_missing(): void + { + $resolver = new CmsParentSegmentResolver(new CanonicalUrlResolver); + + $path = $resolver->joinPublicLeafPath(Page::class, 'en', 'about'); + + $this->assertSame('/about', $path); + } + + public function test_resolves_prefix_and_joins_leaf(): void + { + $this->createParentSegmentTables(); + + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => '', + 'normalized_prefix' => 'blog/kurumsal', + 'admin_label' => 'Blog', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $resolver = new CmsParentSegmentResolver(new CanonicalUrlResolver); + + $this->assertSame('/blog/kurumsal/hakkimizda', $resolver->joinPublicLeafPath(Page::class, 'en', 'hakkimizda')); + } + + public function test_join_respects_explicit_empty_binding_as_locale_root(): void + { + $this->createParentSegmentTables(); + + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => '', + 'normalized_prefix' => '', + 'admin_label' => 'Home', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $resolver = new CmsParentSegmentResolver(new CanonicalUrlResolver); + + $this->assertSame('/', $resolver->joinPublicLeafPath(Page::class, 'en', '')); + + $this->assertSame('/contact', $resolver->joinPublicLeafPath(Page::class, 'tr', 'contact')); + } + + protected function createParentSegmentTables(): void + { + $bindings = modularityConfig('tables.cms_parent_segment_bindings', 'um_cms_parent_segment_bindings'); + + Schema::dropIfExists($bindings); + + Schema::create($bindings, function (Blueprint $table): void { + $table->id(); + $table->string('target_model_class', 512); + $table->string('locale', 12)->default(''); + $table->string('normalized_prefix', 2048); + $table->string('admin_label')->nullable(); + $table->boolean('enabled')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['target_model_class', 'locale'], 'cms_psb_model_locale_unq'); + $table->index(['target_model_class', 'locale', 'enabled'], 'cms_psb_model_loc_en_idx'); + }); + } + + protected function dropParentSegmentTables(): void + { + $bindings = modularityConfig('tables.cms_parent_segment_bindings', 'um_cms_parent_segment_bindings'); + Schema::dropIfExists($bindings); + + $targets = modularityConfig('tables.cms_parent_segment_targets', 'um_cms_parent_segment_targets'); + $segments = modularityConfig('tables.cms_parent_segments', 'um_cms_parent_segments'); + Schema::dropIfExists($targets); + Schema::dropIfExists($segments); + } +} diff --git a/tests/Services/Cms/CmsPathLocaleMergeTest.php b/tests/Services/Cms/CmsPathLocaleMergeTest.php new file mode 100644 index 000000000..1bad0f727 --- /dev/null +++ b/tests/Services/Cms/CmsPathLocaleMergeTest.php @@ -0,0 +1,27 @@ +app['config']->set('translatable.locales', ['tr', 'en']); + + $merged = CmsPathLocale::mergeMcamaraKeysWithSiteLocales(['en']); + + $this->assertEqualsCanonicalizing(['tr', 'en'], $merged); + } + + public function test_merge_handles_null_mcamara_keys(): void + { + $this->app['config']->set('translatable.locales', ['de']); + + $merged = CmsPathLocale::mergeMcamaraKeysWithSiteLocales(null); + + $this->assertSame(['de'], $merged); + } +} diff --git a/tests/Services/Cms/CmsPathLocaleTest.php b/tests/Services/Cms/CmsPathLocaleTest.php new file mode 100644 index 000000000..44bf619ae --- /dev/null +++ b/tests/Services/Cms/CmsPathLocaleTest.php @@ -0,0 +1,28 @@ +app['config']->set('modularity.cms_routing.path_segment_locales', ['xx', 'yy']); + + $this->assertSame(['xx', 'yy'], CmsPathLocale::pathSegmentLocales()); + } + + public function test_path_segment_locales_falls_back_to_translatable_locales(): void + { + $this->app['config']->set('modularity.cms_routing.path_segment_locales', null); + $this->app['config']->set('translatable.locales', ['tr', 'en']); + + $locales = CmsPathLocale::pathSegmentLocales(); + + $this->assertContains('tr', $locales); + $this->assertContains('en', $locales); + $this->assertCount(2, $locales); + } +} diff --git a/tests/Services/Cms/CmsPromotionServiceTest.php b/tests/Services/Cms/CmsPromotionServiceTest.php new file mode 100644 index 000000000..7091b9c75 --- /dev/null +++ b/tests/Services/Cms/CmsPromotionServiceTest.php @@ -0,0 +1,101 @@ +createMock(SecurityService::class); + $security->method('canPromote')->willReturn(true); + + $service = new CmsPromotionService($security, new DefaultCmsPromotionScopeApplier); + + $report = $service->promote([ + 'dry_run' => true, + 'scope' => [ + 'settings' => true, + 'content' => true, + 'seo' => true, + 'redirects' => true, + 'layouts' => true, + ], + ], null); + + $this->assertTrue($report['ok']); + $this->assertArrayHasKey('meta', $report['diff']); + $this->assertFalse($report['diff']['settings_changes']['available']); + $this->assertFalse($report['diff']['content_changes']['pages']['available']); + } + + public function test_dry_run_diff_counts_rows_when_minimal_schema_present(): void + { + $security = $this->createMock(SecurityService::class); + $security->method('canPromote')->willReturn(true); + + $this->createMinimalCmsSchema(); + + $t = modularityConfig('tables.cms_site_settings', 'um_cms_site_settings'); + DB::table($t)->insert([ + 'group_key' => 'seo', + 'key' => 'x', + 'locale' => 'en', + 'value' => '1', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new CmsPromotionService($security, new DefaultCmsPromotionScopeApplier); + + $report = $service->promote([ + 'dry_run' => true, + 'scope' => ['settings' => true], + ], null); + + $this->assertTrue($report['ok']); + $this->assertTrue($report['diff']['settings_changes']['available']); + $this->assertSame(1, $report['diff']['settings_changes']['total_rows']); + $this->assertSame(1, $report['diff']['settings_changes']['active_rows']); + $this->assertSame(['seo' => 1], $report['diff']['settings_changes']['rows_by_group']); + } + + public function test_promote_denied_when_user_cannot_approve(): void + { + $security = $this->createMock(SecurityService::class); + $security->method('canPromote')->willReturn(false); + + $service = new CmsPromotionService($security, new DefaultCmsPromotionScopeApplier); + + $report = $service->promote(['dry_run' => true, 'scope' => []], null); + + $this->assertFalse($report['ok']); + $this->assertSame('approval_check', $report['stage']); + } + + /** + * Columns aligned with {@see SiteSetting} for aggregate queries only. + */ + protected function createMinimalCmsSchema(): void + { + $t = modularityConfig('tables.cms_site_settings', 'um_cms_site_settings'); + Schema::dropIfExists($t); + Schema::create($t, function (Blueprint $table): void { + $table->id(); + $table->string('group_key'); + $table->string('key'); + $table->string('locale'); + $table->text('value')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } +} diff --git a/tests/Services/Cms/CmsPublicPathHierarchyTest.php b/tests/Services/Cms/CmsPublicPathHierarchyTest.php new file mode 100644 index 000000000..51312834a --- /dev/null +++ b/tests/Services/Cms/CmsPublicPathHierarchyTest.php @@ -0,0 +1,30 @@ +assertTrue(CmsPublicPathHierarchy::segmentsOverlapAsPrefix('/blog', '/blog/post')); + $this->assertTrue(CmsPublicPathHierarchy::segmentsOverlapAsPrefix('/blog/post', '/blog')); + } + + public function test_no_false_positive_on_shared_word_prefix(): void + { + $this->assertFalse(CmsPublicPathHierarchy::segmentsOverlapAsPrefix('/blog', '/blogging')); + } + + public function test_equal_paths_not_overlap(): void + { + $this->assertFalse(CmsPublicPathHierarchy::segmentsOverlapAsPrefix('/same', '/same')); + } + + public function test_root_does_not_flag_everything(): void + { + $this->assertFalse(CmsPublicPathHierarchy::segmentsOverlapAsPrefix('/', '/foo')); + } +} diff --git a/tests/Services/Cms/CmsPublicSeoTest.php b/tests/Services/Cms/CmsPublicSeoTest.php new file mode 100644 index 000000000..0d822bbda --- /dev/null +++ b/tests/Services/Cms/CmsPublicSeoTest.php @@ -0,0 +1,45 @@ + 'T', + 'title' => 'T', + 'seo_description' => null, + 'canonical_url' => null, + 'robots_index' => null, + 'robots_follow' => null, + ]; + + $canonical = new \Modules\Cms\Services\CanonicalUrlResolver; + $out = CmsPublicSeo::build($request, $translation, $canonical); + + $this->assertSame('index, follow', $out['robotsMeta']); + } + + public function test_custom_canonical_absolute_is_preserved(): void + { + $request = Request::create('https://example.test/cms/foo', 'GET'); + $translation = (object) [ + 'seo_title' => 'T', + 'title' => 'T', + 'canonical_url' => 'https://other.example/path', + 'robots_index' => true, + 'robots_follow' => true, + ]; + + $canonical = new \Modules\Cms\Services\CanonicalUrlResolver; + $out = CmsPublicSeo::build($request, $translation, $canonical); + + $this->assertSame('https://other.example/path', $out['canonicalUrl']); + } +} diff --git a/tests/Services/Cms/CmsPublicSiteUrlTest.php b/tests/Services/Cms/CmsPublicSiteUrlTest.php new file mode 100644 index 000000000..f34ad934e --- /dev/null +++ b/tests/Services/Cms/CmsPublicSiteUrlTest.php @@ -0,0 +1,38 @@ +app['config']->set('modularity.cms_routing.public_front_route_domain', 'frontend.example.test'); + $this->app['config']->set('app.url', 'http://admin.example.test'); + + $url = CmsPublicSiteUrl::absoluteUrlForPath('/en/blog/post'); + + $this->assertSame('http://frontend.example.test/en/blog/post', $url); + } + + public function test_resolve_host_prefers_public_front_domain_over_canonical(): void + { + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', 'a.test'); + $this->app['config']->set('modularity.cms_routing.canonical_host', 'b.test'); + + $this->assertSame('a.test', CmsPublicSiteUrl::resolvePublicSiteHost()); + } + + public function test_absolute_url_falls_back_to_url_helper_when_no_host_resolved(): void + { + $this->app['config']->set('modularity.cms_routing.public_front_route_domain', null); + $this->app['config']->set('modularity.cms_routing.canonical_host', ''); + $this->app['config']->set('modularity.cms_routing.bind_public_routes_to_app_url_host', false); + + $url = CmsPublicSiteUrl::absoluteUrlForPath('/tr/foo'); + + $this->assertStringEndsWith('/tr/foo', $url); + } +} diff --git a/tests/Services/Cms/CmsSignedPreviewUrlGeneratorTest.php b/tests/Services/Cms/CmsSignedPreviewUrlGeneratorTest.php new file mode 100644 index 000000000..b84a5f7c0 --- /dev/null +++ b/tests/Services/Cms/CmsSignedPreviewUrlGeneratorTest.php @@ -0,0 +1,27 @@ +app['config']->set('modularity.cms_routing.signed_preview.ttl_minutes', 3); + + $generator = new CmsSignedPreviewUrlGenerator; + + $this->assertSame(5, $generator->ttlMinutes()); + } + + public function test_ttl_minutes_uses_config_when_above_minimum(): void + { + $this->app['config']->set('modularity.cms_routing.signed_preview.ttl_minutes', 90); + + $generator = new CmsSignedPreviewUrlGenerator; + + $this->assertSame(90, $generator->ttlMinutes()); + } +} diff --git a/tests/Services/Cms/CmsSiteSeoSettingsServiceTest.php b/tests/Services/Cms/CmsSiteSeoSettingsServiceTest.php new file mode 100644 index 000000000..d4ab15855 --- /dev/null +++ b/tests/Services/Cms/CmsSiteSeoSettingsServiceTest.php @@ -0,0 +1,64 @@ + true, + 'modularity.cms_seo.robots.global_robots_txt' => 'User-agent: *\nAllow: /', + 'modularity.cms_seo.robots.site_setting' => [ + 'group_key' => 'seo', + 'key' => 'global_robots_txt', + 'locale' => '*', + ], + ]); + + $row = $this->getMockBuilder(SiteSetting::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + $row->setRawAttributes([ + 'value' => "User-agent: *\nDisallow: /staging", + ]); + + $repo = $this->createMock(SiteSettingRepository::class); + $repo->method('findScoped')->with('seo', 'global_robots_txt', '*')->willReturn($row); + + $service = new CmsSiteSeoSettingsService($repo); + $body = $service->resolvedRobotsTxtBody(); + + $this->assertStringContainsString('Disallow: /staging', $body); + $this->assertStringEndsWith("\n", $body); + } + + public function test_resolved_body_static_accepts_injected_service(): void + { + config([ + 'modularity.cms_seo.robots.use_site_settings' => true, + 'modularity.cms_seo.robots.site_setting' => [ + 'group_key' => 'seo', + 'key' => 'global_robots_txt', + 'locale' => '*', + ], + ]); + + $repo = $this->createMock(SiteSettingRepository::class); + $repo->method('findScoped')->willReturn(null); + + $service = new CmsSiteSeoSettingsService($repo); + config(['modularity.cms_seo.robots.global_robots_txt' => 'User-agent: *\nDisallow: /cfg']); + + $body = RobotsTxtController::resolvedBody($service); + + $this->assertStringContainsString('/cfg', $body); + } +} diff --git a/tests/Services/Cms/CmsSitemapBuildServiceTest.php b/tests/Services/Cms/CmsSitemapBuildServiceTest.php new file mode 100644 index 000000000..58c875e05 --- /dev/null +++ b/tests/Services/Cms/CmsSitemapBuildServiceTest.php @@ -0,0 +1,72 @@ +createUrlRoutesTable(); + $this->app->singleton(CanonicalUrlResolverInterface::class, CanonicalUrlResolver::class); + $this->app['config']->set('modularity.cms_routing.path_segment_locales', ['en']); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + $this->app->instance( + CmsLocalizationContract::class, + new TranslatableCmsLocalizationAdapter(new CanonicalUrlResolver) + ); + } + + public function test_build_produces_minimal_urlset_when_no_rows(): void + { + $build = $this->app->make(CmsSitemapBuildService::class); + $xml = $build->buildXml(); + $this->assertStringContainsString('assertStringNotContainsString('', $xml); + } + + public function test_cache_commit_and_persist(): void + { + $cache = $this->app->make(CmsSitemapCacheService::class); + $key = 'modularity_cms_sitemap.committed_v1'; + $this->app['config']->set('modularity.cms_sitemap.cache_key', $key); + Cache::store()->clear(); + $sample = '' . "\n"; + $cache->commit($sample); + $this->assertSame($sample, $cache->getCommittedXml()); + } + + public function test_get_panel_item_rows_is_empty_without_urlable_data(): void + { + $build = $this->app->make(CmsSitemapBuildService::class); + $rows = $build->getPanelItemRows(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + protected function createUrlRoutesTable(): void + { + $t = modularityConfig('tables.cms_url_routes', 'um_cms_url_routes'); + Schema::dropIfExists($t); + Schema::create($t, function (Blueprint $table): void { + $table->id(); + $table->string('locale', 12)->index(); + $table->string('normalized_path', 2048); + $table->morphs('urlable'); + $table->string('kind', 32)->nullable()->index(); + $table->timestamps(); + $table->unique(['locale', 'normalized_path']); + }); + } +} diff --git a/tests/Services/Cms/CmsSlugPathSegmentPolicyTest.php b/tests/Services/Cms/CmsSlugPathSegmentPolicyTest.php new file mode 100644 index 000000000..4d1ad95df --- /dev/null +++ b/tests/Services/Cms/CmsSlugPathSegmentPolicyTest.php @@ -0,0 +1,67 @@ +app['config']->set('modularity.cms_routing.admin.slug_max_path_segments', null); + + parent::tearDown(); + } + + public function test_slug_path_segment_policy_respects_max_one(): void + { + $this->app['config']->set('modularity.cms_routing.admin.slug_max_path_segments', 1); + + $resolver = new CanonicalUrlResolver; + $service = new CmsSlugInputValidationService(new CmsParentSegmentResolver($resolver)); + $invoke = $this->invokePolicyFailure($service); + + $this->assertNull($invoke('foo')); + $this->assertNull($invoke('')); + $this->assertNotNull($invoke('foo/bar')); + $this->assertNotNull($invoke('foo\\bar')); + } + + public function test_slug_path_segment_policy_allows_two_when_max_two(): void + { + $this->app['config']->set('modularity.cms_routing.admin.slug_max_path_segments', 2); + + $resolver = new CanonicalUrlResolver; + $service = new CmsSlugInputValidationService(new CmsParentSegmentResolver($resolver)); + $invoke = $this->invokePolicyFailure($service); + + $this->assertNull($invoke('a/b')); + $this->assertNotNull($invoke('a/b/c')); + } + + public function test_slug_path_segment_policy_unlimited_when_null(): void + { + $this->app['config']->set('modularity.cms_routing.admin.slug_max_path_segments', null); + + $resolver = new CanonicalUrlResolver; + $service = new CmsSlugInputValidationService(new CmsParentSegmentResolver($resolver)); + $invoke = $this->invokePolicyFailure($service); + + $this->assertNull($invoke('a/b/c/d')); + } + + /** + * @return callable(string): ?string + */ + private function invokePolicyFailure(CmsSlugInputValidationService $service): callable + { + $method = new ReflectionMethod(CmsSlugInputValidationService::class, 'slugPathSegmentPolicyFailure'); + $method->setAccessible(true); + + return static fn (string $raw): ?string => $method->invoke($service, $raw); + } +} diff --git a/tests/Services/Cms/CmsSluglessFallbackLocaleRoutingTest.php b/tests/Services/Cms/CmsSluglessFallbackLocaleRoutingTest.php new file mode 100644 index 000000000..0509f0380 --- /dev/null +++ b/tests/Services/Cms/CmsSluglessFallbackLocaleRoutingTest.php @@ -0,0 +1,48 @@ +app['config']->set('translatable.fallback_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'tr'); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment', true); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment_locale', null); + + $canonical = new CanonicalUrlResolver; + $localization = new TranslatableCmsLocalizationAdapter($canonical); + $resolver = new CmsVisitorRedirectResolver($canonical, $localization); + + [$locale, $path, $explicit] = $resolver->resolveLocaleAndInnerPath('/pages/test'); + + $this->assertFalse($explicit); + $this->assertSame('/pages/test', $path); + $this->assertSame('en', $locale); + $this->assertSame('en', CmsSluglessFallbackLocale::resolvedCode()); + } + + public function test_implicit_path_uses_cms_default_locale_when_slugless_toggle_off(): void + { + $this->app['config']->set('translatable.fallback_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'tr'); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment', false); + + $canonical = new CanonicalUrlResolver; + $localization = new TranslatableCmsLocalizationAdapter($canonical); + $resolver = new CmsVisitorRedirectResolver($canonical, $localization); + + [$locale, $path, $explicit] = $resolver->resolveLocaleAndInnerPath('/pages/test'); + + $this->assertFalse($explicit); + $this->assertSame('/pages/test', $path); + $this->assertSame('tr', $locale); + } +} diff --git a/tests/Services/Cms/CmsUrlRouteRegistryClaimTest.php b/tests/Services/Cms/CmsUrlRouteRegistryClaimTest.php new file mode 100644 index 000000000..fd4d2de94 --- /dev/null +++ b/tests/Services/Cms/CmsUrlRouteRegistryClaimTest.php @@ -0,0 +1,181 @@ +createUrlRoutesTable(); + $this->app->singleton(CanonicalUrlResolverInterface::class, CanonicalUrlResolver::class); + } + + public function test_is_path_claimed_by_other_respects_excluded_page(): void + { + $canonical = app(CanonicalUrlResolverInterface::class); + $registry = new CmsUrlRouteRegistry($canonical, new CmsParentSegmentResolver($canonical)); + + UrlRoute::query()->create([ + 'locale' => 'en', + 'normalized_path' => '/shared-path', + 'urlable_type' => Page::class, + 'urlable_id' => 10, + 'kind' => UrlRoute::KIND_PAGE_PUBLIC, + ]); + + $this->assertTrue($registry->isPathClaimedByOther('en', '/shared-path', Page::class, 20)); + $this->assertFalse($registry->isPathClaimedByOther('en', '/shared-path', Page::class, 10)); + $this->assertFalse($registry->isPathClaimedByOther('tr', '/shared-path', Page::class, 20)); + } + + public function test_is_path_claimed_by_other_detects_legacy_rows_without_leading_slash(): void + { + $canonical = app(CanonicalUrlResolverInterface::class); + $registry = new CmsUrlRouteRegistry($canonical, new CmsParentSegmentResolver($canonical)); + + UrlRoute::query()->create([ + 'locale' => 'tr', + 'normalized_path' => 'sayfalar/test', + 'urlable_type' => Page::class, + 'urlable_id' => 7, + 'kind' => UrlRoute::KIND_PAGE_PUBLIC, + ]); + + $this->assertTrue($registry->isPathClaimedByOther('tr', '/sayfalar/test', Page::class, 99)); + $this->assertFalse($registry->isPathClaimedByOther('tr', '/sayfalar/test', Page::class, 7)); + } + + public function test_desired_public_paths_use_fallback_leaf_when_locale_slug_is_inactive(): void + { + $this->app['config']->set('translatable.locales', ['tr', 'en']); + $this->app['config']->set('modularity.cms_parent_segments.enabled', false); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + $this->app['config']->set('translatable.fallback_locale', null); + + $canonical = app(CanonicalUrlResolverInterface::class); + $registry = new CmsUrlRouteRegistry($canonical, new CmsParentSegmentResolver($canonical)); + + $page = new class extends Model + { + protected $table = 'stub_desired_public_paths'; + }; + + $page->setRelation('slugs', collect([ + (object) ['locale' => 'tr', 'slug' => 'deneme', 'active' => false], + (object) ['locale' => 'en', 'slug' => 'test', 'active' => true], + ])); + + $method = new ReflectionMethod(CmsUrlRouteRegistry::class, 'desiredPublicPathsByLocale'); + $method->setAccessible(true); + /** @var array $paths */ + $paths = $method->invoke($registry, $page); + + $this->assertSame('/test', $paths['en']); + $this->assertSame('/test', $paths['tr']); + $this->assertCount(2, $paths); + } + + public function test_desired_public_paths_emit_all_get_locales_when_only_subset_has_slug_rows(): void + { + $this->app['config']->set('translatable.locales', ['tr', 'en']); + $this->app['config']->set('modularity.cms_parent_segments.enabled', false); + $this->app['config']->set('modularity.cms_routing.default_locale', 'en'); + + $canonical = app(CanonicalUrlResolverInterface::class); + $registry = new CmsUrlRouteRegistry($canonical, new CmsParentSegmentResolver($canonical)); + + $page = new class extends Model + { + protected $table = 'stub_desired_public_paths'; + }; + + $page->setRelation('slugs', collect([ + (object) ['locale' => 'en', 'slug' => 'test', 'active' => true], + ])); + + $method = new ReflectionMethod(CmsUrlRouteRegistry::class, 'desiredPublicPathsByLocale'); + $method->setAccessible(true); + /** @var array $paths */ + $paths = $method->invoke($registry, $page); + + $this->assertSame('/test', $paths['en']); + $this->assertSame('/test', $paths['tr']); + $this->assertCount(2, $paths); + } + + public function test_desired_public_paths_empty_when_no_active_slug_segments(): void + { + $this->app['config']->set('modularity.cms_parent_segments.enabled', false); + $canonical = app(CanonicalUrlResolverInterface::class); + $registry = new CmsUrlRouteRegistry($canonical, new CmsParentSegmentResolver($canonical)); + + $page = new class extends Model + { + protected $table = 'stub_desired_public_paths'; + }; + + $page->setRelation('slugs', collect([ + (object) ['locale' => 'tr', 'slug' => 'deneme', 'active' => false], + ])); + + $method = new ReflectionMethod(CmsUrlRouteRegistry::class, 'desiredPublicPathsByLocale'); + $method->setAccessible(true); + $paths = $method->invoke($registry, $page); + + $this->assertSame([], $paths); + } + + public function test_sync_public_page_routes_for_all_models_early_returns_when_class_missing(): void + { + $this->expectNotToPerformAssertions(); + + $canonical = app(CanonicalUrlResolverInterface::class); + $registry = new CmsUrlRouteRegistry($canonical, new CmsParentSegmentResolver($canonical)); + $registry->syncPublicPageRoutesForAllModelsOfClass('App\\Definitely\\NonexistentClassForRegistry999'); + } + + public function test_sync_public_page_routes_for_all_models_early_returns_when_model_lacks_slug_traits(): void + { + $this->expectNotToPerformAssertions(); + + $canonical = app(CanonicalUrlResolverInterface::class); + $registry = new CmsUrlRouteRegistry($canonical, new CmsParentSegmentResolver($canonical)); + + $plain = new class extends Model { + /** @inheritdoc */ + protected $table = 'stub_plain_sync_all'; + }; + + $registry->syncPublicPageRoutesForAllModelsOfClass($plain::class); + } + + protected function createUrlRoutesTable(): void + { + $t = modularityConfig('tables.cms_url_routes', 'um_cms_url_routes'); + Schema::dropIfExists($t); + Schema::create($t, function (Blueprint $table): void { + $table->id(); + $table->string('locale', 12)->index(); + $table->string('normalized_path', 2048); + $table->morphs('urlable'); + $table->string('kind', 32)->nullable()->index(); + $table->timestamps(); + + $table->unique(['locale', 'normalized_path']); + }); + } +} diff --git a/tests/Services/Cms/CmsVisitorRedirectResolverImplicitLocaleActivePathTest.php b/tests/Services/Cms/CmsVisitorRedirectResolverImplicitLocaleActivePathTest.php new file mode 100644 index 000000000..6a5a499d1 --- /dev/null +++ b/tests/Services/Cms/CmsVisitorRedirectResolverImplicitLocaleActivePathTest.php @@ -0,0 +1,114 @@ +createUrlRoutesTable(); + $this->app->singleton(CanonicalUrlResolverInterface::class, CanonicalUrlResolver::class); + } + + public function test_implicit_path_not_active_when_only_non_fallback_locale_row_exists_slugless_on(): void + { + $this->app['config']->set('translatable.fallback_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'tr'); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment', true); + + $canonical = app(CanonicalUrlResolverInterface::class); + $localization = new TranslatableCmsLocalizationAdapter($canonical); + $resolver = new CmsVisitorRedirectResolver($canonical, $localization); + + UrlRoute::query()->create([ + 'locale' => 'tr', + 'normalized_path' => '/sayfalar/deneme-2', + 'urlable_type' => Page::class, + 'urlable_id' => 1, + 'kind' => UrlRoute::KIND_PAGE_PUBLIC, + ]); + + $this->assertFalse($resolver->isActivePagePath('en', '/sayfalar/deneme-2', false)); + $this->assertTrue($resolver->isActivePagePath('tr', '/sayfalar/deneme-2', true)); + } + + public function test_implicit_path_active_for_fallback_locale_row_when_slugless_on(): void + { + $this->app['config']->set('translatable.fallback_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'tr'); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment', true); + + $canonical = app(CanonicalUrlResolverInterface::class); + $localization = new TranslatableCmsLocalizationAdapter($canonical); + $resolver = new CmsVisitorRedirectResolver($canonical, $localization); + + UrlRoute::query()->create([ + 'locale' => 'en', + 'normalized_path' => '/pages/test', + 'urlable_type' => Page::class, + 'urlable_id' => 2, + 'kind' => UrlRoute::KIND_PAGE_PUBLIC, + ]); + + $this->assertTrue($resolver->isActivePagePath('en', '/pages/test', false)); + } + + public function test_implicit_path_only_matches_cms_default_locale_when_slugless_off(): void + { + $this->app['config']->set('translatable.fallback_locale', 'en'); + $this->app['config']->set('modularity.cms_routing.default_locale', 'tr'); + $this->app['config']->set('modularity.cms_routing.fallback_locale_optional_path_segment', false); + + $canonical = app(CanonicalUrlResolverInterface::class); + $localization = new TranslatableCmsLocalizationAdapter($canonical); + $resolver = new CmsVisitorRedirectResolver($canonical, $localization); + + UrlRoute::query()->create([ + 'locale' => 'tr', + 'normalized_path' => '/sayfalar/deneme-2', + 'urlable_type' => Page::class, + 'urlable_id' => 3, + 'kind' => UrlRoute::KIND_PAGE_PUBLIC, + ]); + + $this->assertTrue($resolver->isActivePagePath('tr', '/sayfalar/deneme-2', false)); + + UrlRoute::query()->create([ + 'locale' => 'en', + 'normalized_path' => '/pages/only-en', + 'urlable_type' => Page::class, + 'urlable_id' => 4, + 'kind' => UrlRoute::KIND_PAGE_PUBLIC, + ]); + + $this->assertFalse($resolver->isActivePagePath('tr', '/pages/only-en', false)); + } + + protected function createUrlRoutesTable(): void + { + $t = modularityConfig('tables.cms_url_routes', 'um_cms_url_routes'); + Schema::dropIfExists($t); + Schema::create($t, function (Blueprint $table): void { + $table->id(); + $table->string('locale', 12)->index(); + $table->string('normalized_path', 2048); + $table->morphs('urlable'); + $table->string('kind', 32)->nullable()->index(); + $table->timestamps(); + + $table->unique(['locale', 'normalized_path']); + }); + } +} diff --git a/tests/Services/Cms/CmsVisitorRedirectResolverTest.php b/tests/Services/Cms/CmsVisitorRedirectResolverTest.php new file mode 100644 index 000000000..493255096 --- /dev/null +++ b/tests/Services/Cms/CmsVisitorRedirectResolverTest.php @@ -0,0 +1,102 @@ + ['en', 'tr']]); + config(['modularity.cms_routing.default_locale' => 'en']); + + $resolver = $this->makeResolver(); + + [$locale, $inner, $explicit] = $resolver->resolveLocaleAndInnerPath('/tr/foo/bar'); + + $this->assertSame('tr', $locale); + $this->assertSame('/foo/bar', $inner); + $this->assertTrue($explicit); + } + + public function test_resolve_locale_and_inner_path_uses_default_when_no_prefix(): void + { + config(['translatable.locales' => ['en', 'tr']]); + config(['modularity.cms_routing.default_locale' => 'en']); + + $resolver = $this->makeResolver(); + + [$locale, $inner, $explicit] = $resolver->resolveLocaleAndInnerPath('/about'); + + $this->assertSame('en', $locale); + $this->assertSame('/about', $inner); + $this->assertFalse($explicit); + } + + public function test_resolve_locale_prefers_longer_locale_codes_first(): void + { + config(['translatable.locales' => ['pt', 'pt-br']]); + config(['modularity.cms_routing.default_locale' => 'en']); + + $resolver = $this->makeResolver(); + + [$locale, $inner, $explicit] = $resolver->resolveLocaleAndInnerPath('/pt-br/produtos'); + + $this->assertSame('pt-br', $locale); + $this->assertSame('/produtos', $inner); + $this->assertTrue($explicit); + } + + public function test_resolve_locale_path_key_prefers_route_locale_and_path_parameters(): void + { + config(['translatable.locales' => ['en', 'tr']]); + config(['modularity.cms_routing.default_locale' => 'en']); + + $resolver = $this->makeResolver(); + + $request = Request::create('http://frontend.test/en/blog/my-post', 'GET'); + $route = new Route(['GET'], '{locale}/{path}', []); + $route->where('path', '.*'); + $route->bind($request); + $request->setRouteResolver(static fn () => $route); + + [$locale, $pathKey, $explicit] = $resolver->resolveLocalePathKeyAndExplicitFlag($request); + + $this->assertSame('en', $locale); + $this->assertSame('/blog/my-post', $pathKey); + $this->assertTrue($explicit); + } + + public function test_resolve_locale_path_key_falls_back_when_route_locale_not_in_allowed_locales(): void + { + config(['translatable.locales' => ['en', 'tr']]); + config(['modularity.cms_routing.default_locale' => 'en']); + + $resolver = $this->makeResolver(); + + $request = Request::create('http://frontend.test/xx/blog/my-post', 'GET'); + $route = new Route(['GET'], '{locale}/{path}', []); + $route->where('path', '.*'); + $route->bind($request); + $request->setRouteResolver(static fn () => $route); + + [$locale, $pathKey, $explicit] = $resolver->resolveLocalePathKeyAndExplicitFlag($request); + + $this->assertSame('en', $locale); + $this->assertSame('/xx/blog/my-post', $pathKey); + $this->assertFalse($explicit); + } +} diff --git a/tests/Services/Cms/ParentSegmentBindingValidatorTest.php b/tests/Services/Cms/ParentSegmentBindingValidatorTest.php new file mode 100644 index 000000000..e7778720a --- /dev/null +++ b/tests/Services/Cms/ParentSegmentBindingValidatorTest.php @@ -0,0 +1,145 @@ +id(); + $table->string('target_model_class', 512); + $table->string('locale', 12)->default(''); + $table->string('normalized_prefix', 2048); + $table->string('admin_label')->nullable(); + $table->boolean('enabled')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['target_model_class', 'locale'], 'cms_psb_model_locale_unq_test'); + $table->index(['target_model_class', 'locale', 'enabled'], 'cms_psb_model_loc_en_idx_test'); + }); + } + + public function test_locale_scopes_overlap_wildcards(): void + { + $this->assertTrue(ParentSegmentBindingValidator::localeScopesOverlap('en', '')); + $this->assertTrue(ParentSegmentBindingValidator::localeScopesOverlap('', 'tr')); + $this->assertTrue(ParentSegmentBindingValidator::localeScopesOverlap('fr', 'fr')); + $this->assertFalse(ParentSegmentBindingValidator::localeScopesOverlap('en', 'de')); + } + + public function test_second_empty_prefix_on_same_locale_for_different_model_is_rejected(): void + { + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => '', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $this->expectException(ValidationException::class); + + ParentSegmentBindingValidator::assertExclusiveEmptyPrefixAcrossTargetsIfEnabled( + true, + HomepageTest::class, + 'en', + '', + null, + ); + } + + public function test_empty_prefix_on_non_overlapping_specific_locales_allowed(): void + { + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => '', + 'enabled' => true, + 'sort_order' => 0, + ]); + + ParentSegmentBindingValidator::assertExclusiveEmptyPrefixAcrossTargetsIfEnabled( + true, + HomepageTest::class, + 'tr', + '', + null, + ); + + ParentSegment::query()->create([ + 'target_model_class' => HomepageTest::class, + 'locale' => 'tr', + 'normalized_prefix' => '', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $this->assertSame(2, ParentSegment::query()->where('enabled', true)->whereRaw("TRIM(COALESCE(normalized_prefix, '')) = ?", [''])->count()); + } + + public function test_wildcard_locale_empty_prefix_conflicts_with_specific_locale_binding(): void + { + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => '', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $this->expectException(ValidationException::class); + + ParentSegmentBindingValidator::assertExclusiveEmptyPrefixAcrossTargetsIfEnabled( + true, + HomepageTest::class, + '', + '', + null, + ); + } + + public function test_disabled_rows_do_not_block_empty_prefix_claim(): void + { + ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => '', + 'enabled' => false, + 'sort_order' => 0, + ]); + + ParentSegmentBindingValidator::assertExclusiveEmptyPrefixAcrossTargetsIfEnabled( + true, + HomepageTest::class, + 'en', + '', + null, + ); + + ParentSegment::query()->create([ + 'target_model_class' => HomepageTest::class, + 'locale' => 'en', + 'normalized_prefix' => '', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $this->assertSame(1, ParentSegment::query()->where('enabled', true)->whereRaw("TRIM(COALESCE(normalized_prefix, '')) = ?", [''])->count()); + } +} diff --git a/tests/Services/Cms/ParentSegmentUrlRouteObserverTest.php b/tests/Services/Cms/ParentSegmentUrlRouteObserverTest.php new file mode 100644 index 000000000..31c20ea11 --- /dev/null +++ b/tests/Services/Cms/ParentSegmentUrlRouteObserverTest.php @@ -0,0 +1,179 @@ +id(); + $table->string('target_model_class'); + $table->string('locale', 24)->nullable(); + $table->string('normalized_prefix', 2048)->nullable(); + $table->string('admin_label', 191)->nullable(); + $table->boolean('enabled')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + }); + + $this->app['config']->set('modularity.cms_parent_segments.enabled', true); + $this->app['config']->set('modularity.cms_routing.resync_registry_after_parent_segments_change', true); + $this->app->singleton(CanonicalUrlResolverInterface::class, CanonicalUrlResolver::class); + } + + public function test_created_requests_resync_for_target_class(): void + { + $registry = $this->createMock(PublicUrlRegistryContract::class); + $registry->expects($this->once()) + ->method('syncPublicPageRoutesForAllModelsOfClass') + ->with(Page::class); + + $observer = new ParentSegmentUrlRouteObserver($registry); + $segment = ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'enabled' => true, + 'sort_order' => 0, + ]); + $observer->created($segment); + } + + public function test_updated_skips_non_url_relevant_field_changes(): void + { + $registry = $this->createMock(PublicUrlRegistryContract::class); + $registry->expects($this->never())->method('syncPublicPageRoutesForAllModelsOfClass'); + + $observer = new ParentSegmentUrlRouteObserver($registry); + $segment = ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $segment->admin_label = 'renamed'; + $observer->saving($segment); + $segment->save(); + $observer->updated($segment); + } + + public function test_updated_resyncs_when_prefix_changes(): void + { + $registry = $this->createMock(PublicUrlRegistryContract::class); + $registry->expects($this->once()) + ->method('syncPublicPageRoutesForAllModelsOfClass') + ->with(Page::class); + + $observer = new ParentSegmentUrlRouteObserver($registry); + $segment = ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'enabled' => true, + 'sort_order' => 0, + ]); + $segment->normalized_prefix = 'blog'; + $observer->saving($segment); + $segment->save(); + + $observer->updated($segment); + } + + public function test_updated_resyncs_both_targets_when_target_model_class_changes(): void + { + $calls = []; + + $registry = $this->createMock(PublicUrlRegistryContract::class); + $registry->expects($this->exactly(2)) + ->method('syncPublicPageRoutesForAllModelsOfClass') + ->willReturnCallback(function (string $class) use (&$calls): void { + $calls[] = $class; + }); + + $observer = new ParentSegmentUrlRouteObserver($registry); + $segment = ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'enabled' => true, + 'sort_order' => 0, + ]); + + $segment->target_model_class = 'App\\Blog\\BlogPost'; + $observer->saving($segment); + $segment->save(); + + $observer->updated($segment); + + $this->assertSame([Page::class, 'App\\Blog\\BlogPost'], $calls); + } + + public function test_deleted_requests_resync_for_target_class(): void + { + $registry = $this->createMock(PublicUrlRegistryContract::class); + $registry->expects($this->once()) + ->method('syncPublicPageRoutesForAllModelsOfClass') + ->with(Page::class); + + $observer = new ParentSegmentUrlRouteObserver($registry); + $segment = ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'enabled' => true, + 'sort_order' => 0, + ]); + $segment->delete(); + $observer->deleted($segment); + } + + public function test_observer_skips_when_resync_disabled_in_config(): void + { + $this->app['config']->set('modularity.cms_routing.resync_registry_after_parent_segments_change', false); + + $registry = $this->createMock(PublicUrlRegistryContract::class); + $registry->expects($this->never())->method('syncPublicPageRoutesForAllModelsOfClass'); + + $observer = new ParentSegmentUrlRouteObserver($registry); + $segment = ParentSegment::query()->create([ + 'target_model_class' => Page::class, + 'locale' => 'en', + 'normalized_prefix' => 'pages', + 'enabled' => true, + 'sort_order' => 0, + ]); + $observer->created($segment); + $observer->deleted($segment); + } + + /** Smoke: container binds {@see ParentSegmentUrlRouteObserver} via {@see CmsServiceProvider}. */ + public function test_cms_service_provider_resolves_observer(): void + { + $this->app['config']->set('modularity.cms_features.enabled', true); + $this->app['config']->set('modularity.cms_routing.resync_registry_after_parent_segments_change', true); + + $this->app->register(\Modules\Cms\Providers\CmsServiceProvider::class); + + /** @var ParentSegmentUrlRouteObserver $observer Resolved with {@see PublicUrlRegistryContract}. */ + $observer = $this->app->make(ParentSegmentUrlRouteObserver::class); + + $this->assertInstanceOf(ParentSegmentUrlRouteObserver::class, $observer); + } +} diff --git a/tests/Services/Cms/RedirectBulkImportServiceTest.php b/tests/Services/Cms/RedirectBulkImportServiceTest.php new file mode 100644 index 000000000..0409713b0 --- /dev/null +++ b/tests/Services/Cms/RedirectBulkImportServiceTest.php @@ -0,0 +1,150 @@ +app->make(RedirectController::class); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->app['config']->set('modularity.cms_features.enabled', true); + $this->app['config']->set('modularity.cms_features.register_contracts', false); + $this->app['config']->set('modularity.tables.cms_redirects', 'um_cms_redirects'); + $this->app['config']->set('modularity.tables.cms_url_routes', 'um_cms_url_routes'); + + $this->createRedirectsTable(); + $this->createUrlRoutesTable(); + + $this->app->register(CmsServiceProvider::class); + } + + public function test_dry_run_does_not_persist(): void + { + $csv = "locale,from_path,to_path,status_code,is_active\nen,/a,/b,301,1\n"; + $service = $this->app->make(BulkImportService::class); + $result = $service->import($csv, true, $this->redirectBulkSheet(), self::TOOL_KEY); + + $this->assertTrue($result['ok']); + $this->assertSame(0, Redirect::query()->count()); + } + + public function test_dry_run_accepts_semicolon_delimiter(): void + { + $csv = "locale;from_path;to_path;status_code;is_active\r\nen;/back;/pages/test;301;1\r\n"; + $service = $this->app->make(BulkImportService::class); + $result = $service->import($csv, true, $this->redirectBulkSheet(), self::TOOL_KEY); + + $this->assertTrue($result['ok'], (string) ($result['message'] ?? '')); + $this->assertSame(0, Redirect::query()->count()); + } + + public function test_commit_creates_rows(): void + { + $csv = "locale,from_path,to_path,status_code,is_active\nen,/a,/b,301,1\n"; + $service = $this->app->make(BulkImportService::class); + $result = $service->import($csv, false, $this->redirectBulkSheet(), self::TOOL_KEY); + + $this->assertTrue($result['ok']); + $this->assertSame(1, Redirect::query()->count()); + $this->assertSame('/b', Redirect::query()->first()->to_path); + } + + public function test_commit_updates_existing_by_locale_and_from_path(): void + { + Redirect::query()->create([ + 'from_path' => '/a', + 'to_path' => '/old', + 'locale' => 'en', + 'status_code' => 301, + 'is_active' => true, + ]); + + $csv = "locale,from_path,to_path\nen,/a,/new\n"; + $service = $this->app->make(BulkImportService::class); + $result = $service->import($csv, false, $this->redirectBulkSheet(), self::TOOL_KEY); + + $this->assertTrue($result['ok']); + $this->assertSame(1, Redirect::query()->count()); + $this->assertSame('/new', Redirect::query()->first()->to_path); + } + + public function test_rejects_loop_in_batch(): void + { + $csv = "locale,from_path,to_path\nen,/a,/b\nen,/b,/a\n"; + $service = $this->app->make(BulkImportService::class); + $result = $service->import($csv, true, $this->redirectBulkSheet(), self::TOOL_KEY); + + $this->assertFalse($result['ok']); + } + + public function test_invalid_first_row_does_not_block_second_with_same_from(): void + { + $csv = "locale,from_path,to_path\nen,/a,/a\nen,/a,/b\n"; + $service = $this->app->make(BulkImportService::class); + $result = $service->import($csv, true, $this->redirectBulkSheet(), self::TOOL_KEY); + + $this->assertFalse($result['ok']); + $rows = $result['rows']; + $this->assertFalse($rows[0]['valid']); + $this->assertTrue($rows[1]['valid']); + } + + public function test_empty_csv_fails(): void + { + $service = $this->app->make(BulkImportService::class); + $result = $service->import('', true, $this->redirectBulkSheet(), self::TOOL_KEY); + + $this->assertFalse($result['ok']); + } + + protected function createRedirectsTable(): void + { + $t = 'um_cms_redirects'; + Schema::dropIfExists($t); + Schema::create($t, function (Blueprint $table): void { + $table->id(); + $table->string('from_path'); + $table->string('to_path'); + $table->string('locale', 12)->index(); + $table->unsignedSmallInteger('status_code')->default(301); + $table->boolean('is_active')->default(true); + $table->softDeletes(); + $table->timestamps(); + + $table->unique(['from_path', 'locale']); + }); + } + + protected function createUrlRoutesTable(): void + { + $t = 'um_cms_url_routes'; + Schema::dropIfExists($t); + Schema::create($t, function (Blueprint $table): void { + $table->id(); + $table->string('locale', 12)->index(); + $table->string('normalized_path', 2048); + $table->morphs('urlable'); + $table->string('kind', 32)->nullable()->index(); + $table->timestamps(); + + $table->unique(['locale', 'normalized_path']); + }); + } +} diff --git a/tests/Services/Cms/RedirectValidationServiceTest.php b/tests/Services/Cms/RedirectValidationServiceTest.php new file mode 100644 index 000000000..58db8b6a0 --- /dev/null +++ b/tests/Services/Cms/RedirectValidationServiceTest.php @@ -0,0 +1,59 @@ +makeService(); + + $self = $service->validate('/about', '/about'); + $loop = $service->validate('/a', '/b', [ + 'existing_redirects' => [ + '/b' => '/a', + ], + ]); + + $this->assertFalse($self['valid']); + $this->assertFalse($loop['valid']); + } + + public function test_it_blocks_redirect_conflicting_with_active_page(): void + { + $service = $this->makeService(); + + $validation = $service->validate('/home', '/landing', [ + 'active_paths' => ['/home'], + ]); + + $this->assertFalse($validation['valid']); + } + + public function test_it_returns_warnings_for_cross_locale_redirect_without_failing(): void + { + config(['translatable.locales' => ['en', 'tr']]); + + $service = $this->makeService(); + + $validation = $service->validate('/en/source', '/tr/target', []); + + $this->assertTrue($validation['valid']); + $this->assertNotEmpty($validation['warnings']); + } +} diff --git a/tests/Services/Cms/RobotsTxtControllerTest.php b/tests/Services/Cms/RobotsTxtControllerTest.php new file mode 100644 index 000000000..8adb955ba --- /dev/null +++ b/tests/Services/Cms/RobotsTxtControllerTest.php @@ -0,0 +1,30 @@ + "User-agent: *\nDisallow: /private", + ]); + + $body = RobotsTxtController::resolvedBody(); + + $this->assertStringEndsWith("\n", $body); + $this->assertStringContainsString('Disallow: /private', $body); + } + + public function test_resolved_body_falls_back_when_empty_config(): void + { + config(['modularity.cms_seo.robots.global_robots_txt' => ' ']); + + $body = RobotsTxtController::resolvedBody(); + + $this->assertStringContainsString('User-agent', $body); + } +} diff --git a/tests/Services/Cms/ScanCmsPublishWindowBoundariesJobTest.php b/tests/Services/Cms/ScanCmsPublishWindowBoundariesJobTest.php new file mode 100644 index 000000000..1e38b2dff --- /dev/null +++ b/tests/Services/Cms/ScanCmsPublishWindowBoundariesJobTest.php @@ -0,0 +1,18 @@ +app['config']->set('modularity.cms_schedule.enabled', false); + + (new ScanCmsPublishWindowBoundariesJob)->handle(); + + $this->assertTrue(true); + } +} diff --git a/tests/Services/CoverageServiceTest.php b/tests/Services/CoverageServiceTest.php index f337424dc..5a3e4425f 100644 --- a/tests/Services/CoverageServiceTest.php +++ b/tests/Services/CoverageServiceTest.php @@ -125,7 +125,7 @@ public function git_parses_branch_references_correctly() // Test that different branch formats are handled $mock = new class($this->cloverDir, $this->cloverName) extends CoverageService { - public function test_get_git_changed_files(string $baseBranch): array + public function testGetGitChangedFiles(string $baseBranch): array { // Call the private method through reflection $method = new \ReflectionMethod(parent::class, 'getGitChangedFiles'); diff --git a/tests/Services/Security/SecurityServiceTest.php b/tests/Services/Security/SecurityServiceTest.php new file mode 100644 index 000000000..215fd18de --- /dev/null +++ b/tests/Services/Security/SecurityServiceTest.php @@ -0,0 +1,52 @@ +set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'google_totp'); + config()->set('modularity.security.mfa.required_roles', ['admin']); + + $user = \Mockery::mock(Authenticatable::class); + $user->shouldReceive('hasRole')->with('admin')->andReturn(true); + $user->google_2fa_enabled = false; + $user->google_2fa_secret = null; + + $service = new SecurityService; + + $this->assertTrue($service->userRequiresMfa($user)); + $this->assertFalse($service->userHasEnabledMfa($user)); + } + + public function test_email_otp_provider_does_not_require_google_2fa_columns(): void + { + config()->set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'email_otp'); + + $user = \Mockery::mock(Authenticatable::class); + + $service = new SecurityService; + + $this->assertTrue($service->userHasEnabledMfa($user)); + } + + public function test_field_permission_checks_are_applied(): void + { + config()->set('modularity.security.critical_field_permissions.canonical_url', 'cms-seo-override_edit'); + + $user = \Mockery::mock(Authenticatable::class); + $user->shouldReceive('can')->with('cms-seo-override_edit')->andReturn(true); + + $service = new SecurityService; + + $this->assertTrue($service->canWriteField($user, 'canonical_url')); + $this->assertTrue($service->canWriteField($user, 'non_critical_field')); + } +} diff --git a/tests/Services/SlugInputValidationServiceTest.php b/tests/Services/SlugInputValidationServiceTest.php new file mode 100644 index 000000000..b630129ad --- /dev/null +++ b/tests/Services/SlugInputValidationServiceTest.php @@ -0,0 +1,28 @@ +validateModelSlug(TestModel::class, 'any-slug', 'en', true, null); + + $this->assertFalse($result['valid']); + $this->assertNotEmpty($result['message']); + } + + public function test_propose_rejects_model_without_has_slug_trait(): void + { + $this->expectException(\InvalidArgumentException::class); + + $service = app(SlugInputValidationService::class); + $service->proposeUniqueSlugForModel(TestModel::class, 'hello-world', 'en', true, null); + } +} diff --git a/tests/Support/CommandDiscoveryTest.php b/tests/Support/CommandDiscoveryTest.php index b20a113b1..3a88c5d9d 100644 --- a/tests/Support/CommandDiscoveryTest.php +++ b/tests/Support/CommandDiscoveryTest.php @@ -69,6 +69,7 @@ public function it_discovers_commands_from_make_folder(): void $this->assertNotEmpty($commands); $this->assertContains('Unusualify\Modularity\Console\Make\MakeModuleCommand', $commands); $this->assertContains('Unusualify\Modularity\Console\Make\MakeRouteCommand', $commands); + $this->assertContains('Unusualify\Modularity\Console\Make\MakeCmsControllerCommand', $commands); } /** @test */ diff --git a/tests/Support/TranslatableMetadataTest.php b/tests/Support/TranslatableMetadataTest.php new file mode 100644 index 000000000..791febdc3 --- /dev/null +++ b/tests/Support/TranslatableMetadataTest.php @@ -0,0 +1,32 @@ +assertSame( + TranslatableMetadata::TRANSLATED_ATTRIBUTES, + get_class($dummy)::translatableMetadataAttributeNames() + ); + } + + public function test_default_form_inputs_cover_all_translated_keys(): void + { + $names = array_column(TranslatableMetadata::defaultFormInputs(), 'name'); + + foreach (TranslatableMetadata::TRANSLATED_ATTRIBUTES as $attr) { + $this->assertContains($attr, $names, "Missing form input for [{$attr}]"); + } + } +} diff --git a/tests/Traits/ManageTraitsTest.php b/tests/Traits/ManageTraitsTest.php index 717d2fa18..917cf6488 100644 --- a/tests/Traits/ManageTraitsTest.php +++ b/tests/Traits/ManageTraitsTest.php @@ -2,9 +2,9 @@ namespace Unusualify\Modularity\Tests\Traits; -use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Hash; -use Unusualify\Modularity\Facades\ModularityFinder; +use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Module; use Unusualify\Modularity\Tests\TestCase; use Unusualify\Modularity\Traits\ManageTraits; @@ -29,11 +29,6 @@ public function getRouteName() { return 'TestRoute'; } - - public function getModule() - { - return null; - } }; } @@ -95,16 +90,12 @@ public function it_can_chunk_inputs() /** @test */ public function it_can_resolve_model() { - ModularityFinder::shouldReceive('getRouteRepository') - ->with('TestRoute') - ->andReturn('TestRepository'); - - $repo = \Mockery::mock('TestRepository'); - $repo->shouldReceive('getModel')->andReturn('TestModel'); + $module = \Mockery::mock(Module::class); + $module->shouldReceive('hasRoute')->with('TestRoute')->andReturn(true); + $module->shouldReceive('getModel')->with('TestRoute')->once()->andReturn('TestModel'); - App::shouldReceive('make') - ->with('TestRepository') - ->andReturn($repo); + // isModuleRouteClass() and getModule() each call Modularity::find() with the same name. + Modularity::shouldReceive('find')->with('TestModule')->andReturn($module); $this->assertEquals('TestModel', $this->target->model()); } diff --git a/tests/Traits/MiscTraitsTest.php b/tests/Traits/MiscTraitsTest.php index febd70e5e..6ffa3481a 100644 --- a/tests/Traits/MiscTraitsTest.php +++ b/tests/Traits/MiscTraitsTest.php @@ -112,7 +112,7 @@ public function it_can_use_traitify_trait() { use Traitify; - public function test_method_traitify() + public function testMethodTraitify() { return 'success'; } diff --git a/tests_/ModularityTest.php b/tests_/ModularityTest.php deleted file mode 100644 index 43080384b..000000000 --- a/tests_/ModularityTest.php +++ /dev/null @@ -1,107 +0,0 @@ -app = new Container; - $this->app->instance('cache', app('cache')); - $this->app->instance('config', app('config')); - - $path = base_path('packages/modularity'); - dd($path); - - $this->modularity = new Modularity($this->app); - } - - public function _test_scan_paths_are_properly_formatted() - { - $paths = $this->modularity->getScanPaths(); - - foreach ($paths as $path) { - $this->assertTrue(str_ends_with($path, '/*')); - } - } - - public function _test_cache_can_be_disabled() - { - $this->modularity->disableCache(); - - $this->assertFalse(config('modules.cache.enabled')); - } - - public function _test_cache_can_be_cleared() - { - Config::set('modules.cache.enabled', true); - Config::set('modules.cache.key', 'test-modules-cache'); - - Cache::shouldReceive('forget') - ->once() - ->with('test-modules-cache'); - - $this->modularity->clearCache(); - } - - public function _test_can_get_grouped_modules() - { - // Mock a system module - $modules = [ - 'test-module' => new Module('test-module', '/path/to/module'), - ]; - - $this->modularity->shouldReceive('allEnabled') - ->once() - ->andReturn($modules); - - $systemModules = $this->modularity->getSystemModules(); - - $this->assertIsArray($systemModules); - } - - public function _test_can_delete_module() - { - $moduleName = 'test-module'; - - // Test non-existent module - $this->assertFalse($this->modularity->deleteModule($moduleName)); - - // Test existing module - $module = new Module($moduleName, '/path/to/module'); - $this->modularity->shouldReceive('all') - ->once() - ->andReturn([$module]); - - $module->shouldReceive('delete') - ->once() - ->andReturn(true); - - $this->assertTrue($this->modularity->deleteModule($moduleName)); - } - - public function _test_can_get_vendor_path() - { - $path = $this->modularity->getVendorPath('test'); - $this->assertIsString($path); - $this->assertTrue(str_contains($path, 'test')); - } - - public function _test_can_get_vendor_namespace() - { - $namespace = $this->modularity->getVendorNamespace('Test'); - $this->assertIsString($namespace); - } -} diff --git a/vue/FRONTEND_TEST_PLAN.md b/vue/FRONTEND_TEST_PLAN.md new file mode 100644 index 000000000..0e30cc96c --- /dev/null +++ b/vue/FRONTEND_TEST_PLAN.md @@ -0,0 +1,300 @@ +# Frontend Test Plan – Modularous Vue Package + +## Executive Summary + +This document analyzes the current Vitest frontend test coverage, identifies gaps, and provides a step-by-step plan to achieve comprehensive test coverage for the modularous Vue package. + +--- + +## 1. Current State (as of analysis) + +### Test Results +- **Total tests**: 187 +- **Passing**: 174 +- **Failing**: 13 (4 test files) +- **Test files**: 26 total (22 passed, 4 failed) + +### Test Structure +``` +vue/ +├── test/ # Co-located tests (test/*.test.js) +│ ├── components/ # Component tests +│ ├── composables/ # Hook/composable tests +│ ├── utils/ # Utility tests +│ ├── store/ # Vuex store tests +│ └── example.test.js +├── src/js/ +│ ├── components/inputs/__tests__/ # registry.spec.js +│ └── utils/__tests__/ # helpers.spec.js, schema.spec.js +└── vitest.config.mjs +``` + +### Existing Test Patterns +- **Factory pattern**: `factory(props, options)` for mounting with global plugins +- **Plugins**: UEConfig, Vuetify (createVuetify), i18n, store +- **Stubs**: `ue-recursive-stuff`, `ue-dynamic-component-renderer`, `ue-title` +- **Directives**: resize, intersect, touch, click-outside (stubbed in tests) + +--- + +## 2. Failing Tests – Root Causes & Fixes + +### 2.1 ue-alert.test.js & ue-callout.test.js +**Error**: `[Vuetify] Could not find defaults instance` + +**Cause**: The `vuetify` import from `../../src/js/plugins/vuetify` exports a **function** (`createModularityVuetify`), not a Vuetify instance. Tests use `plugins: [vuetify]` but must use `plugins: [vuetify()]` or `plugins: [createModularityVuetify()]`. + +**Fix**: +```js +import createModularityVuetify from '../../src/js/plugins/vuetify' +const vuetify = createModularityVuetify() +// Then: plugins: [vuetify] +``` + +**Note**: The modularity vuetify plugin may have side effects (require.context, theme loading) that fail in Vitest. If so, use a minimal `createVuetify({ components, directives })` like v-custom-form-base.test.js. + +--- + +### 2.2 v-input-image.test.js +**Errors**: +- `openMediaLibrary does not exist` – Image.vue uses Composition API or setup(); `Image.methods` is undefined +- `data-test="addButton"` – Add button only renders when `input[index] === undefined`; with `modelValue: []` the structure differs +- Vuetify defaults / fitGrid directive + +**Cause**: Image.vue structure changed; tests target Options API `methods` and wrong DOM structure. + +**Fix**: +- Use `wrapper.vm.openMediaLibrary` or spy on the component instance after mount +- Ensure factory passes `modelValue` that yields the expected DOM (empty array vs undefined slots) +- Add Vuetify plugin and stub fitGrid directive + +--- + +### 2.3 v-input-assignment.test.js +**Errors**: +- `axios.post` not called – mock/async timing +- `wrapper.vm.assignments[0].status` not updated – mock response not applied +- `wrapper.vm.$refs.createFormModal.dialog` undefined – refs not available in test +- `wrapper.vm.$refs.createForm` undefined – form ref structure + +**Cause**: Refs, async flows, and mocks not aligned with component implementation. + +**Fix**: +- Use `vi.stubGlobal` or inject axios mock correctly +- Await `flushPromises()` after async operations +- Stub child components that provide refs, or use `attachTo: document.body` for refs +- Simplify tests to focus on unit behavior rather than full integration + +--- + +## 3. Coverage Gaps – Missing Tests + +### 3.1 Input Components (registry.js hydrateTypeMap) + +| Component | File | Test Exists | Priority | +|-----------|------|-------------|----------| +| VInputChecklist | Checklist.vue | ✅ | - | +| VInputTagger | Tagger.vue | ✅ | - | +| VInputImage | Image.vue | ✅ (broken) | Fix first | +| VInputAssignment | Assignment.vue | ✅ (broken) | Fix first | +| VInputProcess | Process.vue | ✅ | - | +| VInputSpread | Spread.vue | ✅ | - | +| VInputChat | Chat.vue | ✅ | - | +| VInputFile | File.vue | ❌ | High | +| VInputFilepond | Filepond.vue | ❌ | High | +| VInputPrice | Price.vue | ❌ | High | +| VInputDate | Date.vue | ❌ | Medium | +| VInputSelectScroll | SelectScroll.vue | ❌ | Medium | +| VInputRepeater | Repeater.vue | ❌ | Medium | +| VInputTag | Tag.vue | ❌ | High | +| VInputBrowser | Browser.vue | ❌ | Low | +| VInputRadioGroup | RadioGroup.vue | ❌ | Medium | +| VInputFormTabs | FormTabs.vue | ❌ | Low | +| VInputComparisonTable | ComparisonTable.vue | ❌ | Low | +| VInputChecklistGroup | ChecklistGroup.vue | ❌ | Low | +| VInputPaymentService | PaymentService.vue | ❌ | Low | +| VInputFilepondAvatar | FilepondAvatar.vue | ❌ | Low | + +### 3.2 Composables / Hooks + +| Hook | Test Exists | Priority | +|------|-------------|----------| +| useFormatter | ✅ | - | +| useSidebar | ✅ | - | +| useNavigationLayout | ✅ | - | +| useInput | ❌ | High | +| useValidation | ❌ | High | +| useForm | ❌ | High | +| useFile | ❌ | Medium | +| useFilepond | ❌ | Medium | +| useCurrency | ❌ | Medium | +| useMediaLibrary | ❌ | Medium | +| useRepeater | ❌ | Medium | +| useModal | ❌ | Medium | +| useConfig | ❌ | Low | +| useBadge | ❌ | Low | +| useFormatter (extended) | Partial | - | + +### 3.3 Utils + +| Util | Test Exists | Priority | +|------|-------------|----------| +| helpers | ✅ | - | +| schema | ✅ | - | +| itemConditions | ✅ | - | +| cropper | ✅ | - | +| country | ✅ | - | +| common-methods | ✅ | - | +| formEvents | ❌ | High | +| formEventFormatters/* | ❌ | Medium | +| getFormData | ❌ | Medium | +| response | ❌ | Low | +| locale | ❌ | Low | +| phone | ❌ | Low | +| errors | ❌ | Low | + +### 3.4 Components (Other) + +| Component | Test Exists | Priority | +|-----------|-------------|----------| +| CustomFormBase | ✅ | - | +| FormBase | ❌ | High | +| FormBaseField | ❌ | High | +| InputRenderer | ❌ | High | +| UEConfigurableCard | ✅ | - | +| UECallout | ✅ (broken) | Fix | +| UEAlert | ✅ (broken) | Fix | +| UEConfig | Used in tests | - | +| Modal | ❌ | Medium | +| Datatable | ❌ | Medium | +| Sidebar, Main, SidebarContent | ✅ | - | + +### 3.5 Store + +| Module | Test Exists | +|--------|-------------| +| user | ✅ | + +--- + +## 4. Implementation Plan + +### Phase 1: Fix Failing Tests (Immediate) +1. **ue-alert.test.js** – Use `createVuetify({ components, directives })` or ensure Vuetify instance is correctly passed +2. **ue-callout.test.js** – Same Vuetify fix; Callout uses `title`/`value` props, not `text` – adjust test expectations +3. **v-input-image.test.js** – Update to match current Image.vue structure; fix spies and data-test selectors +4. **v-input-assignment.test.js** – Fix axios mocks, refs, and async handling + +### Phase 2: Input Components (High Priority) +1. Add `v-input-file.test.js` +2. Add `v-input-filepond.test.js` +3. Add `v-input-price.test.js` +4. Add `v-input-tag.test.js` +5. Fix and extend `v-input-image.test.js` + +### Phase 3: Core Form & Utils +1. Add `FormBase.test.js` (or extend CustomFormBase coverage) +2. Add `FormBaseField.test.js` +3. Add `InputRenderer.test.js` +4. Add `formEvents.test.js` +5. Add `getFormData.test.js` + +### Phase 4: Composables +1. Add `useInput.test.js` +2. Add `useValidation.test.js` +3. Add `useForm.test.js` +4. Add `useCurrency.test.js` +5. Add `useFile.test.js` / `useFilepond.test.js` + +### Phase 5: Remaining Components & Utils +1. Add tests for Date, SelectScroll, Repeater, RadioGroup +2. Add tests for Modal, Datatable +3. Add tests for formEventFormatters, response, locale, phone + +--- + +## 5. Test Utilities & Setup + +### Recommended Test Helper +Create `test/helpers/factory.js`: + +```js +import { mount } from '@vue/test-utils' +import { createVuetify } from 'vuetify' +import * as components from 'vuetify/components' +import * as directives from 'vuetify/directives' + +export const vuetify = createVuetify({ components, directives }) + +export const defaultStubs = { + 'ue-recursive-stuff': true, + 'ue-dynamic-component-renderer': true, + 'ue-title': { template: '' } +} + +export const defaultDirectives = { + resize: { mounted: () => {}, unmounted: () => {} }, + intersect: { mounted: () => {}, unmounted: () => {} }, + touch: { mounted: () => {}, unmounted: () => {} }, + 'click-outside': { mounted: () => {}, unmounted: () => {} }, + 'fit-grid': { mounted: () => {}, unmounted: () => {} } +} + +export function createMountOptions(overrides = {}) { + return { + global: { + plugins: [vuetify], + stubs: defaultStubs, + directives: defaultDirectives, + ...overrides.global + }, + attachTo: document.body, + ...overrides + } +} +``` + +### Vitest Setup +Ensure `vitest-setup/jsdom.js` runs before tests. It already: +- Mocks `HTMLCanvasElement.prototype.getContext` +- Adds CSRF meta +- Assigns `window.__*` helpers +- Mocks `ResizeObserver` +- Sets `window[APP_NAME].STORE.config` + +--- + +## 6. Checklist + +- [x] Fix ue-alert.test.js +- [x] Fix ue-callout.test.js +- [x] Fix v-input-image.test.js +- [x] Fix v-input-assignment.test.js +- [ ] Add createMountOptions helper (optional) +- [x] Add useCurrency.test.js +- [x] Add input-renderer.test.js +- [x] Add v-input-file.test.js +- [x] Add v-input-filepond.test.js +- [x] Add v-input-price.test.js +- [x] Add v-input-tag.test.js (covered by existing `v-input-tagger.test.js` for `Tagger.vue`) +- [ ] Add FormBase.test.js +- [ ] Add FormBaseField.test.js +- [ ] Add InputRenderer.test.js +- [ ] Add formEvents.test.js +- [ ] Add useInput.test.js +- [ ] Add useValidation.test.js +- [ ] Add useForm.test.js +- [ ] Add useCurrency.test.js +- [ ] Run `npm run test:coverage` and target >80% for critical paths + +--- + +## 7. Running Tests + +```bash +cd packages/modularous/vue +npm run test # Watch mode +npm run test:run # Single run +npm run test:coverage # Coverage report +npm run test:ui # Vitest UI +``` diff --git a/vue/package-lock.json b/vue/package-lock.json index 6ca7e0a74..d2596ef06 100644 --- a/vue/package-lock.json +++ b/vue/package-lock.json @@ -16,12 +16,16 @@ "axios": "^0.21.4", "cropperjs": "^1.5.13", "date-fns": "^3.4.0", + "diff": "^8.0.4", "filepond": "^4.31.1", "filepond-plugin-file-validate-size": "^2.2.8", "filepond-plugin-file-validate-type": "^1.2.9", "filepond-plugin-image-preview": "^4.6.12", "fine-uploader": "^5.16.2", "fine-uploader-wrappers": "^1.0.1", + "grapesjs": "^0.22.14", + "grapesjs-blocks-basic": "^1.0.2", + "grapesjs-preset-webpage": "^1.0.3", "jquery": "^3.6.3", "laravel-echo": "^1.17.1", "laravel-vite-plugin": "^2.0.1", @@ -1452,11 +1456,27 @@ "node": ">=10.13.0" } }, + "node_modules/@types/backbone": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz", + "integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==", + "license": "MIT", + "dependencies": { + "@types/jquery": "*", + "@types/underscore": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "node_modules/@types/jquery": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==", + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -1487,6 +1507,12 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/underscore": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", + "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/types": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", @@ -2294,6 +2320,26 @@ "node": ">= 10.0.0" } }, + "node_modules/backbone": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz", + "integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==", + "license": "MIT", + "dependencies": { + "underscore": ">=1.8.3" + } + }, + "node_modules/backbone-undo": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz", + "integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "backbone": ">=1.0.0", + "underscore": ">=1.4.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2786,6 +2832,18 @@ "node": ">=0.8" } }, + "node_modules/codemirror": { + "version": "5.63.0", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz", + "integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==", + "license": "MIT" + }, + "node_modules/codemirror-formatting": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz", + "integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3505,6 +3563,15 @@ "node": "^12.20.0 || ^14.14.0 || >=16.0.0" } }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -5575,6 +5642,34 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/grapesjs": { + "version": "0.22.14", + "resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.14.tgz", + "integrity": "sha512-UyHKGtB9YOQ4bmvhmwY3H7J6SO30qgIH4qRp1ucUlblS21btMxY90xwQuAVIi+EFGl0wi8v1NBEYvA52eIEfyg==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/backbone": "1.4.15", + "backbone": "1.4.1", + "backbone-undo": "0.2.6", + "codemirror": "5.63.0", + "codemirror-formatting": "1.0.0", + "html-entities": "~1.4.0", + "promise-polyfill": "8.3.0", + "underscore": "1.13.1" + } + }, + "node_modules/grapesjs-blocks-basic": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/grapesjs-blocks-basic/-/grapesjs-blocks-basic-1.0.2.tgz", + "integrity": "sha512-SsPKf/CvQkZ+kABOsN01auAPHXh/2J20g0AWYF7fHR3Gw3TZLtdIxT1mk90Qzi76u/7sUYi3CTI+i3ZaTtXHRA==", + "license": "BSD-3-Clause" + }, + "node_modules/grapesjs-preset-webpage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/grapesjs-preset-webpage/-/grapesjs-preset-webpage-1.0.3.tgz", + "integrity": "sha512-C0VOKLAdhv0j1f81c6F2uk3JpJvxgXl5DeHUtDa5qGf/HZzaCmQxsvd8Re3Oh5Cah4uUCqoi9uB5FYx3hLND2w==", + "license": "BSD-3-Clause" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -5749,6 +5844,12 @@ "node": ">=18" } }, + "node_modules/html-entities": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", + "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8444,6 +8545,12 @@ "asap": "~2.0.3" } }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "license": "MIT" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -10221,6 +10328,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", + "license": "MIT" + }, "node_modules/uniq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", @@ -12747,11 +12860,25 @@ "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "dev": true }, + "@types/backbone": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz", + "integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==", + "requires": { + "@types/jquery": "*", + "@types/underscore": "*" + } + }, "@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "@types/jquery": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==" + }, "@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -12782,6 +12909,11 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "@types/underscore": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", + "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==" + }, "@typescript-eslint/types": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", @@ -13374,6 +13506,23 @@ "@babel/types": "^7.9.6" } }, + "backbone": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz", + "integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==", + "requires": { + "underscore": ">=1.8.3" + } + }, + "backbone-undo": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz", + "integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==", + "requires": { + "backbone": ">=1.0.0", + "underscore": ">=1.4.4" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -13719,6 +13868,16 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" }, + "codemirror": { + "version": "5.63.0", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz", + "integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==" + }, + "codemirror-formatting": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz", + "integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==" + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -14238,6 +14397,11 @@ "typescript": "^4.9.5" } }, + "diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==" + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -15617,6 +15781,31 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "grapesjs": { + "version": "0.22.14", + "resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.14.tgz", + "integrity": "sha512-UyHKGtB9YOQ4bmvhmwY3H7J6SO30qgIH4qRp1ucUlblS21btMxY90xwQuAVIi+EFGl0wi8v1NBEYvA52eIEfyg==", + "requires": { + "@types/backbone": "1.4.15", + "backbone": "1.4.1", + "backbone-undo": "0.2.6", + "codemirror": "5.63.0", + "codemirror-formatting": "1.0.0", + "html-entities": "~1.4.0", + "promise-polyfill": "8.3.0", + "underscore": "1.13.1" + } + }, + "grapesjs-blocks-basic": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/grapesjs-blocks-basic/-/grapesjs-blocks-basic-1.0.2.tgz", + "integrity": "sha512-SsPKf/CvQkZ+kABOsN01auAPHXh/2J20g0AWYF7fHR3Gw3TZLtdIxT1mk90Qzi76u/7sUYi3CTI+i3ZaTtXHRA==" + }, + "grapesjs-preset-webpage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/grapesjs-preset-webpage/-/grapesjs-preset-webpage-1.0.3.tgz", + "integrity": "sha512-C0VOKLAdhv0j1f81c6F2uk3JpJvxgXl5DeHUtDa5qGf/HZzaCmQxsvd8Re3Oh5Cah4uUCqoi9uB5FYx3hLND2w==" + }, "graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -15745,6 +15934,11 @@ "whatwg-encoding": "^3.1.1" } }, + "html-entities": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", + "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==" + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -17639,6 +17833,11 @@ "asap": "~2.0.3" } }, + "promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==" + }, "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -18943,6 +19142,11 @@ "which-boxed-primitive": "^1.0.2" } }, + "underscore": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" + }, "uniq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", diff --git a/vue/package.json b/vue/package.json index 5f11ae1d8..85544c20f 100755 --- a/vue/package.json +++ b/vue/package.json @@ -14,6 +14,7 @@ "test:run": "vitest run", "test:update": "vitest -u", "test:coverage": "vitest run --coverage", + "test:coverage:xml": "vitest run --coverage --coverage.reporter=clover", "madge": "madge" }, "dependencies": { @@ -25,12 +26,16 @@ "axios": "^0.21.4", "cropperjs": "^1.5.13", "date-fns": "^3.4.0", + "diff": "^8.0.4", "filepond": "^4.31.1", "filepond-plugin-file-validate-size": "^2.2.8", "filepond-plugin-file-validate-type": "^1.2.9", "filepond-plugin-image-preview": "^4.6.12", "fine-uploader": "^5.16.2", "fine-uploader-wrappers": "^1.0.1", + "grapesjs": "^0.22.14", + "grapesjs-blocks-basic": "^1.0.2", + "grapesjs-preset-webpage": "^1.0.3", "jquery": "^3.6.3", "laravel-echo": "^1.17.1", "laravel-vite-plugin": "^2.0.1", diff --git a/vue/src/js/Pages/BulkSheet.vue b/vue/src/js/Pages/BulkSheet.vue new file mode 100644 index 000000000..5062b101d --- /dev/null +++ b/vue/src/js/Pages/BulkSheet.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/vue/src/js/Pages/Layouts/MainLayout.vue b/vue/src/js/Pages/Layouts/MainLayout.vue index 2a046c1b2..5729091ea 100644 --- a/vue/src/js/Pages/Layouts/MainLayout.vue +++ b/vue/src/js/Pages/Layouts/MainLayout.vue @@ -1,8 +1,11 @@ + + diff --git a/vue/src/js/Pages/Promotion.vue b/vue/src/js/Pages/Promotion.vue new file mode 100644 index 000000000..a45a19ab1 --- /dev/null +++ b/vue/src/js/Pages/Promotion.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/vue/src/js/Pages/SiteSeo.vue b/vue/src/js/Pages/SiteSeo.vue new file mode 100644 index 000000000..97cf3c546 --- /dev/null +++ b/vue/src/js/Pages/SiteSeo.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/vue/src/js/components/Alert.vue b/vue/src/js/components/Alert.vue index 5f6996509..4d909d0ed 100755 --- a/vue/src/js/components/Alert.vue +++ b/vue/src/js/components/Alert.vue @@ -23,8 +23,10 @@ /> - -
+
@@ -107,3 +109,11 @@ export default { } } + + diff --git a/vue/src/js/components/Form.vue b/vue/src/js/components/Form.vue index ae8b6e0a7..60ada2c31 100755 --- a/vue/src/js/components/Form.vue +++ b/vue/src/js/components/Form.vue @@ -9,7 +9,8 @@ ref="VForm" :action="actionUrl" method="POST" - v-model="validModel" + :modelValue="validModel" + @update:modelValue="updateFormValid" @submit="submit" :class="formClasses" > @@ -21,6 +22,7 @@ scrollable ? 'flex-grow-0' : '', 'd-flex flex-row pb-2' ]"> +
+ +
-
@@ -276,19 +285,26 @@ $vuetify.display.smAndDown ? 'd-none' : 'd-flex flex-column', ]" :style="{ - ...(rightSlotWidth ? {width: `${rightSlotWidth}px`} : {}), + width: `${rightSlotWidth || rightSlotMinWidth || 300}px`, ...(rightSlotMinWidth ? {minWidth: `${rightSlotMinWidth}px`} : {}), ...(rightSlotMaxWidth ? {maxWidth: `${rightSlotMaxWidth}px`} : {}) }" > + + @@ -316,8 +332,14 @@ + this.manualValidation), - submitForm: computed(() => this.submit) + submitForm: computed(() => this.submit), + mergeFormFieldErrors: computed(() => this.mergeFormFieldErrors), + clearFormFieldErrorKey: computed(() => this.clearFormFieldErrorKey), + resetFieldSchemaErrors: computed(() => this.resetFieldSchemaErrors), } }, setup(props, context) { const store = useStore() const useFormInstance = useForm(props, context) - const { t, te, locale } = useI18n({ useScope: 'global' }) + const { t, te } = useI18n({ useScope: 'global' }) // const i18n = useI18n() const formClasses = computed(() => [ @@ -605,33 +634,6 @@ export default { : title }) - const formColumnAttrs = computed(() => { - return props.hasStickyFrame - ? { - cols: '12', - sm: '12', - md: '12', - lg: '8', - xl: '6', - 'order-lg': '0', - 'order-xl': '0' - } - : { - cols: '12' - } - }) - - const stickyColumnAttrs = computed(() => { - return { - cols: '12', - sm: '12', - md: '12', - lg: '4', - xl: '6', - 'order-lg': '1', - 'order-xl': '1' - } - }) onMounted(() => { let timezoneInput = document.getElementById('timezone_session') @@ -640,14 +642,21 @@ export default { } }) + const hasRightSlotContent = computed(() => + context.slots['right.top'] + || context.slots['right.middle'] + || context.slots['right.bottom'] + || ['right-top', 'right-middle', 'right-bottom'].includes(props.actionsPosition) + ) + return { ...useFormInstance, + t, formClasses, formSlots, titleOptions, - titleSerialized - // formColumnAttrs, - // stickyColumnAttrs + titleSerialized, + hasRightSlotContent, } } } diff --git a/vue/src/js/components/StepUpChallenge.vue b/vue/src/js/components/StepUpChallenge.vue new file mode 100644 index 000000000..57aba1a62 --- /dev/null +++ b/vue/src/js/components/StepUpChallenge.vue @@ -0,0 +1,108 @@ + + + diff --git a/vue/src/js/components/SvgIcon.vue b/vue/src/js/components/SvgIcon.vue index 3be540bea..c80942846 100644 --- a/vue/src/js/components/SvgIcon.vue +++ b/vue/src/js/components/SvgIcon.vue @@ -1,7 +1,10 @@ - diff --git a/vue/src/js/components/TableFormatterCell.vue b/vue/src/js/components/TableFormatterCell.vue index 5d66184aa..e4db477db 100644 --- a/vue/src/js/components/TableFormatterCell.vue +++ b/vue/src/js/components/TableFormatterCell.vue @@ -262,6 +262,8 @@ :readonly="groupContext" :disabled="groupContext" @update:model-value="(v) => !groupContext && itemAction(item, 'switch', v, col.key)" + + v-bind="col?.formatter[1] ?? {}" >