From 39bd850fddafe895dee9e27cc180c71f91c97983 Mon Sep 17 00:00:00 2001 From: roble Date: Tue, 8 Sep 2026 21:34:04 +0100 Subject: [PATCH 1/8] feat: add saucebase/core package and update composer.lock --- composer.json | 9 ++++++++ composer.lock | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f703ffab..12a11eaa 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "laravel/tinker": "^3.0.2", "openplain/filament-shadcn-theme": "^1.1", "saucebase/breadcrumbs": "^1.1", + "saucebase/core": "*", "saucebase/module-installer": "^2.7.0", "spatie/laravel-data": "^4.23", "spatie/laravel-navigation": "^1.3", @@ -159,6 +160,14 @@ "saas boilerplate" ], "repositories": [ + { + "type": "path", + "url": "../packages/*", + "canonical": false, + "options": { + "symlink": true + } + }, { "type": "path", "url": "modules/*", diff --git a/composer.lock b/composer.lock index 8ac30e59..00ac4679 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": "937ccfee30587c011264244773ab2d57", + "content-hash": "55db0d0391b9e2dbf3bd6a8cd2c99fd9", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -6953,6 +6953,65 @@ }, "time": "2026-04-25T09:21:15+00:00" }, + { + "name": "saucebase/core", + "version": "0.1.0", + "dist": { + "type": "path", + "url": "../packages/core", + "reference": "601d473ebb18f0e74670f973c9475a3d2d239cdb" + }, + "require": { + "laravel/framework": "^13.0", + "php": "^8.4" + }, + "require-dev": { + "laravel/pint": "^1.30.5", + "orchestra/testbench": "^11.0", + "phpunit/phpunit": "^12.5.34" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Saucebase\\Core\\CoreServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Saucebase\\Core\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Saucebase\\Core\\Tests\\": "tests/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Saucebase", + "email": "hello@saucebase.dev" + }, + { + "name": "Renan Roble", + "email": "renanroble@gmail.com" + } + ], + "description": "The Saucebase core package — navigation, localization, module infrastructure, the settings modal, and the Inertia layer.", + "keywords": [ + "laravel", + "saas", + "saucebase" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "saucebase/module-installer", "version": "v2.7.0", From 70c429bd74d99d8b50b7617a47ee7d7f42a0124e Mon Sep 17 00:00:00 2001 From: roble Date: Thu, 10 Sep 2026 08:56:47 +0100 Subject: [PATCH 2/8] chore: moving logic to core and tidding up --- .../Commands/GenerateModuleTypesCommand.php | 114 ---- .../SauceBase/RecipeToModuleCommand.php | 431 ------------- .../Commands/SauceBase/SeedModulesCommand.php | 47 -- app/Facades/Navigation.php | 16 - app/Filament/Admin/Pages/.gitkeep | 0 app/Filament/Admin/Pages/GeneralSettings.php | 82 --- .../Admin/Pages/LocalizationSettings.php | 65 -- app/Filament/ModulePlugin.php | 75 --- app/Filament/ModulesPlugin.php | 85 --- app/Filament/Pages/SettingsPage.php | 35 - app/Helpers/Toast.php | 166 ----- app/Helpers/helpers.php | 10 - app/Http/Controllers/IndexController.php | 2 +- .../Controllers/LocalizationController.php | 40 -- app/Http/Controllers/SettingsController.php | 60 -- app/Http/Middleware/HandleAppearance.php | 19 - app/Http/Middleware/HandleInertiaRequests.php | 107 ---- app/Http/Middleware/HandleLocalization.php | 57 -- app/Http/Middleware/SecureHeaders.php | 19 - app/Models/User.php | 2 +- app/Navigation/Section.php | 29 - .../PasswordChangedNotification.php | 66 -- app/Providers/AppServiceProvider.php | 92 +-- app/Providers/BreadcrumbServiceProvider.php | 41 -- app/Providers/Filament/AdminPanelProvider.php | 10 +- app/Providers/MacroServiceProvider.php | 60 -- app/Providers/ModalServiceProvider.php | 46 -- app/Providers/ModuleServiceProvider.php | 102 --- app/Providers/NavigationServiceProvider.php | 38 -- app/Providers/SettingsServiceProvider.php | 64 -- app/Services/FrontendConfig.php | 29 - app/Services/Navigation.php | 333 ---------- app/Settings/GeneralSettings.php | 65 -- app/Settings/LocalizationSettings.php | 113 ---- app/Settings/SectionRegistry.php | 135 ---- app/Settings/SettingsSection.php | 71 -- bootstrap/app.php | 6 +- bootstrap/providers.php | 10 - composer.json | 33 +- composer.lock | 71 +- config/app.php | 2 +- ...1_01_01_000010_create_general_settings.php | 33 - ...01_000012_create_localization_settings.php | 21 - lang/pt_BR.json | 88 +-- routes/navigation.php | 4 +- routes/web.php | 4 +- .../basic/src/Filament/ModulePlugin.php | 2 +- .../src/Providers/ModuleServiceProvider.php | 2 +- .../saucebase/stack/react/views/app.blade.php | 2 +- stubs/saucebase/stack/vue/views/app.blade.php | 2 +- tests/Feature/GeneralSettingsTest.php | 23 +- tests/Feature/IndexControllerTest.php | 2 +- tests/Feature/InertiaSSRTest.php | 2 +- tests/Feature/LocalizationSettingsTest.php | 17 +- .../NotificationTranslatabilityTest.php | 113 ---- tests/Feature/SettingsInfrastructureTest.php | 33 +- tests/Feature/SettingsModalTest.php | 4 +- tests/Feature/SettingsNavigationTest.php | 2 +- tests/TestCase.php | 12 + .../Commands/RecipeToModuleCommandTest.php | 2 +- tests/Unit/Services/NavigationTest.php | 604 ------------------ 61 files changed, 135 insertions(+), 3685 deletions(-) delete mode 100644 app/Console/Commands/GenerateModuleTypesCommand.php delete mode 100644 app/Console/Commands/SauceBase/RecipeToModuleCommand.php delete mode 100644 app/Console/Commands/SauceBase/SeedModulesCommand.php delete mode 100644 app/Facades/Navigation.php create mode 100644 app/Filament/Admin/Pages/.gitkeep delete mode 100644 app/Filament/Admin/Pages/GeneralSettings.php delete mode 100644 app/Filament/Admin/Pages/LocalizationSettings.php delete mode 100644 app/Filament/ModulePlugin.php delete mode 100644 app/Filament/ModulesPlugin.php delete mode 100644 app/Filament/Pages/SettingsPage.php delete mode 100644 app/Helpers/Toast.php delete mode 100644 app/Helpers/helpers.php delete mode 100644 app/Http/Controllers/LocalizationController.php delete mode 100644 app/Http/Controllers/SettingsController.php delete mode 100644 app/Http/Middleware/HandleAppearance.php delete mode 100644 app/Http/Middleware/HandleInertiaRequests.php delete mode 100644 app/Http/Middleware/HandleLocalization.php delete mode 100644 app/Http/Middleware/SecureHeaders.php delete mode 100644 app/Navigation/Section.php delete mode 100644 app/Notifications/PasswordChangedNotification.php delete mode 100644 app/Providers/BreadcrumbServiceProvider.php delete mode 100644 app/Providers/MacroServiceProvider.php delete mode 100644 app/Providers/ModalServiceProvider.php delete mode 100644 app/Providers/ModuleServiceProvider.php delete mode 100644 app/Providers/NavigationServiceProvider.php delete mode 100644 app/Providers/SettingsServiceProvider.php delete mode 100644 app/Services/FrontendConfig.php delete mode 100644 app/Services/Navigation.php delete mode 100644 app/Settings/GeneralSettings.php delete mode 100644 app/Settings/LocalizationSettings.php delete mode 100644 app/Settings/SectionRegistry.php delete mode 100644 app/Settings/SettingsSection.php delete mode 100644 database/settings/0001_01_01_000010_create_general_settings.php delete mode 100644 database/settings/0001_01_01_000012_create_localization_settings.php delete mode 100644 tests/Feature/NotificationTranslatabilityTest.php delete mode 100644 tests/Unit/Services/NavigationTest.php diff --git a/app/Console/Commands/GenerateModuleTypesCommand.php b/app/Console/Commands/GenerateModuleTypesCommand.php deleted file mode 100644 index 8120dd94..00000000 --- a/app/Console/Commands/GenerateModuleTypesCommand.php +++ /dev/null @@ -1,114 +0,0 @@ -getDefinition()->addArgument( - new InputArgument('module', InputArgument::IS_ARRAY, 'Module name(s) to generate types for') - ); - - $this->getDefinition()->addOption( - new InputOption('all', 'a', InputOption::VALUE_NONE, 'Generate types for all enabled modules') - ); - } - - public function handle(): int - { - $modules = $this->resolveModules(); - - $exitCode = self::SUCCESS; - - foreach ($modules as $name) { - if ($this->generateForModule($name) === self::FAILURE) { - $exitCode = self::FAILURE; - } - } - - return $exitCode; - } - - /** @return array */ - private function resolveModules(): array - { - $enabledModules = app(ModuleRegistry::class)->modules()->map->name->values()->all(); - - if ($this->option('all')) { - return $enabledModules; - } - - $given = (array) $this->argument('module'); - - if (! empty($given)) { - return $given; - } - - $selected = multisearch( - label: 'Select modules', - options: function (string $search) use ($enabledModules): array { - return collect(['All', ...$enabledModules]) - ->when(strlen($search) > 0, fn (Collection $items) => $items->filter( - fn ($item) => str_contains(strtolower($item), strtolower($search)) - )) - ->values() - ->toArray(); - }, - required: 'You must select at least one module', - ); - - return in_array('All', $selected) ? $enabledModules : $selected; - } - - private function generateForModule(string $name): int - { - $appPath = base_path("modules/{$name}/src"); - - if (! is_dir($appPath)) { - $this->components->error("Module app path not found: {$appPath}"); - - return self::FAILURE; - } - - $typesDir = base_path("modules/{$name}/resources/js/types"); - - if (! is_dir($typesDir)) { - mkdir($typesDir, 0755, true); - } - - $this->components->task("Generate types · {$name}", function () use ($appPath, $typesDir): bool { - $originalDiscoverTypes = Config::get('typescript-transformer.auto_discover_types'); - $originalOutputFile = Config::get('typescript-transformer.output_file'); - - Config::set('typescript-transformer.auto_discover_types', [$appPath]); - Config::set('typescript-transformer.output_file', "{$typesDir}/generated.d.ts"); - - $output = $this->getOutput()->isVerbose() ? $this->output : null; - Artisan::call('typescript:transform', [], $output); - - Config::set('typescript-transformer.auto_discover_types', $originalDiscoverTypes); - Config::set('typescript-transformer.output_file', $originalOutputFile); - - return true; - }); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/SauceBase/RecipeToModuleCommand.php b/app/Console/Commands/SauceBase/RecipeToModuleCommand.php deleted file mode 100644 index 60ada362..00000000 --- a/app/Console/Commands/SauceBase/RecipeToModuleCommand.php +++ /dev/null @@ -1,431 +0,0 @@ -fs = new SymfonyFilesystem; - } - - public function handle(): bool - { - if (! $this->input->isInteractive() && ! $this->validateForNonInteractive()) { - return false; - } - - $this->moduleName = $this->getModuleName(); - $this->composerVendor = $this->option('vendor') - ?: config('app-modules.modules_vendor') - ?: Str::kebab(config('app-modules.modules_namespace', 'Modules')); - $this->composerType = $this->option('type') ?: 'library'; - $this->moduleConfigPath = base_path(config('app-modules.modules_directory', 'modules')).'/'; - - if (file_exists($this->moduleConfigPath.$this->moduleFolder)) { - error("{$this->moduleFolder} module already exists."); - - return true; - } - - $frontendConfig = app(FrontendConfig::class); - $framework = $frontendConfig->getFramework(); - - if ($framework === null) { - error('No frontend framework selected. Run `saucebase stack vue` or `saucebase stack react` first.'); - - return true; - } - - $isDev = $frontendConfig->isDev(); - - $this->template = $this->getTemplate(); - $this->templatePath = base_path($this->template); - $this->tempFolder = base_path('saucebase-temp'); - - if (! file_exists($this->templatePath)) { - error("{$this->templatePath} path does not exist. Check your config/saucebase.php."); - - return true; - } - - $this->generate(); - $this->writeModuleEntryPoint($framework, $isDev); - $this->registerInTaskfile(); - $this->registerInComposer(); - - info("Starter {$this->moduleName} module generated successfully."); - info('Next steps:'); - info(" composer update {$this->composerVendor}/{$this->moduleFolder}"); - info(' `npm run build` or `npm run dev` to build the frontend assets'); - - return true; - } - - protected function getModuleName(): string - { - $input = $this->argument('module') ?? ''; - - if ($input !== '') { - if (Str::contains($input, ' ')) { - throw new RuntimeException('The module name must not contain spaces.'); - } - - $this->setModuleNames($input); - - return $this->moduleName; - } - - $this->setModuleNames( - text( - label: 'Please enter a name for the module to be created (e.g. invoice-test)', - required: true, - validate: fn (string $value) => match (true) { - strlen($value) < 1 => 'The name must be at least 1 characters.', - Str::contains($value, ' ') => 'The name must not contain spaces.', - default => null, - } - ) - ); - - return $this->moduleName; - } - - protected function setModuleNames(string $input): void - { - $this->moduleName = Str::studly($input); - $this->moduleFolder = Str::kebab($this->moduleName); - } - - protected function getTemplate(): string - { - $templates = config('saucebase.template'); - $input = $this->argument('template') ?? ''; - - if ($input !== '') { - if (array_key_exists($input, $templates)) { - return $templates[$input]; - } - - error("Invalid template \"{$input}\". Valid values: ".implode(', ', array_keys($templates))); - } - - $selected = select('Which recipe would you like to use?', array_keys($templates)); - - return $templates[$selected]; - } - - private function validateForNonInteractive(): bool - { - $templates = config('saucebase.template'); - $missing = []; - - if (blank($this->argument('module'))) { - $missing[] = 'module (e.g. php artisan saucebase:recipe MyModule "Basic Recipe")'; - } - - $template = $this->argument('template') ?? ''; - if ($template === '' || ! array_key_exists($template, $templates)) { - $missing[] = 'template — valid values: '.implode(', ', array_keys($templates)); - } - - foreach ($missing as $msg) { - error("Missing required argument in non-interactive mode: {$msg}"); - } - - return empty($missing); - } - - protected function generate(): void - { - $this->deleteDir($this->tempFolder); - $this->copyDir($this->templatePath, $this->tempFolder.'/Module'); - - $finder = (new Finder)->files()->in($this->tempFolder); - - $this->renameFiles($finder); - $this->updateFilesContent($finder); - - $this->copyDir($this->tempFolder.'/'.$this->moduleName, $this->moduleConfigPath.$this->moduleFolder); - $this->deleteDir($this->tempFolder); - } - - protected function renameFiles(Finder $finder): void - { - foreach ($finder as $file) { - $type = Str::endsWith($file->getPath(), ['migrations', 'Migrations']) ? 'migration' : ''; - $this->alterFilename($file->getPathname(), $type); - } - } - - protected function alterFilename(string $sourceFile, string $type = ''): void - { - $name = $this->moduleName; - $model = Str::singular($name); - - $projectPath = base_path(); - $relativePath = substr($sourceFile, strlen($projectPath)); - - $targetFile = $projectPath.str_replace( - ['Module', 'module', strtolower($name).'_plural', 'Model', 'model'], - [$name, Str::kebab($name), Str::kebab(Str::plural($name)), $model, Str::kebab($model)], - $relativePath - ); - - if (in_array(basename($sourceFile), config('saucebase.ignore_files'), true)) { - $targetFile = dirname($targetFile).'/'.basename($sourceFile); - } - - $targetFile = str_replace('Entities', 'Models', $targetFile); - - // prevent "Modules" directory from being renamed to "{Name}s" - if (Str::contains($targetFile, $name.'s')) { - $targetFile = str_replace($name.'s', 'Modules', $targetFile); - } - - $targetFile = str_replace($name.'_plural', Str::plural($name), $targetFile); - - $dir = dirname($targetFile); - if (! is_dir($dir) && ! mkdir($dir, 0777, true) && ! is_dir($dir)) { - throw new RuntimeException("Directory \"{$dir}\" could not be created."); - } - - if ($this->fs->exists($sourceFile)) { - $this->fs->rename($sourceFile, $type === 'migration' ? $this->prependTimestamp($targetFile) : $targetFile, true); - } - } - - protected function prependTimestamp(string $path): string - { - return str_replace(basename($path), date('Y_m_d_his_').basename($path), $path); - } - - protected function updateFilesContent(Finder $finder): void - { - foreach ($finder as $file) { - $this->replaceInFile($file->getPathname()); - } - } - - protected function replaceInFile(string $sourceFile): void - { - if (! file_exists($sourceFile)) { - return; - } - - $map = $this->placeholders($this->moduleName); - $content = file_get_contents($sourceFile); - file_put_contents($sourceFile, str_replace(array_keys($map), array_values($map), $content)); - } - - protected function placeholders(string $name): array - { - $model = Str::singular($name); - $modelPlural = Str::plural($model); - $namespace = config('app-modules.modules_namespace', 'Modules'); - - return [ - // Module — base - '{Module}' => $name, - '{module}' => strtolower($name), - - // Module — case variants - '{Module_}' => $this->splitStudlyWords($name, '_'), - '{module_}' => Str::snake($name), - '{Module-}' => $this->splitStudlyWords($name, '-'), - '{module-}' => Str::kebab($name), - - // Module — camel / studly - '{moduleCamel}' => lcfirst($name), - '{ModuleStudly}' => $name, - '{moduleStudly}' => $name, - - // Module — human readable - '{Module }' => $this->spaceify($name), - '{module }' => strtolower($this->spaceify($name)), - '{ModuleTitle}' => $this->titleCase($name), - '{module_title}' => $this->titleCase($name), - - // Module — plural - '{module_plural}' => strtolower($this->spaceify(Str::plural($name))), - '{module_plural_snake}' => Str::snake(Str::plural($name)), - '{module_plural_kebab}' => Str::kebab(Str::plural($name)), - - // Model — base - '{Model}' => $model, - '{model}' => strtolower($model), - - // Model — case variants - '{Model_}' => $this->splitStudlyWords($model, '_'), - '{model_}' => Str::snake($model), - '{Model-}' => $this->splitStudlyWords($model, '-'), - '{model-}' => Str::kebab($model), - - // Model — camel / studly - '{modelCamel}' => lcfirst($model), - '{ModelStudly}' => $model, - '{modelStudly}' => $model, - - // Model — human readable - '{Model }' => $this->spaceify($model), - '{model }' => strtolower($this->spaceify($model)), - '{ModelTitle}' => $this->titleCase($model), - '{model_title}' => $this->titleCase($model), - - // Model — plural - '{model_plural}' => strtolower($this->spaceify($modelPlural)), - '{model_plural_snake}' => Str::snake($modelPlural), - '{model_plural_kebab}' => Str::kebab($modelPlural), - - // JS-friendly underscored variants (IDE-safe in stub files) - '___Module___' => $name, - '___module___' => strtolower($name), - '___MODULE_NAMESPACE___' => $namespace, - - // Namespace - '{MODULE_NAMESPACE}' => $namespace, - - // Composer vendor / type - '{COMPOSER_VENDOR}' => $this->composerVendor, - '{COMPOSER_TYPE}' => $this->composerType, - ]; - } - - private function splitStudlyWords(string $value, string $separator, bool $lowercase = false): string - { - $parts = preg_split('/(?=[A-Z])/', $value, -1, PREG_SPLIT_NO_EMPTY); - - return implode($separator, $lowercase ? array_map('strtolower', $parts) : $parts); - } - - private function spaceify(string $value): string - { - return trim((string) preg_replace('/(?moduleConfigPath.$this->moduleFolder.'/resources/js/app.ts'; - - if (! $isDev) { - // Install mode: for React, remove the stale app.ts proxy — app.tsx is the real entry. - if ($framework === 'react' && file_exists($entryPoint)) { - $this->fs->remove($entryPoint); - } - - return; - } - - // Dev mode: both subdirs exist, write the proxy. - file_put_contents($entryPoint, "export * from './{$framework}/app';\n"); - - $rel = "modules/{$this->moduleFolder}/resources/js/app.ts"; - exec('git -C '.escapeshellarg(base_path())." update-index --skip-worktree {$rel} 2>&1", $output, $exitCode); - - if ($exitCode !== 0) { - $this->warn("Could not skip-worktree {$rel} (not in git index)."); - } - } - - protected function registerInComposer(): void - { - $path = base_path('composer.json'); - $data = json_decode((string) file_get_contents($path), true); - $package = "{$this->composerVendor}/{$this->moduleFolder}"; - - if (isset($data['require'][$package])) { - return; - } - - $data['require'][$package] = '*'; - - file_put_contents( - $path, - json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n" - ); - } - - protected function registerInTaskfile(): void - { - $taskfile = base_path('Taskfile.yml'); - - if (! file_exists($taskfile)) { - return; - } - - $content = file_get_contents($taskfile); - - if ($content === false) { - throw new RuntimeException("Unable to read Taskfile at [{$taskfile}]."); - } - - $entry = " {$this->moduleFolder}:\n taskfile: ./modules/{$this->moduleFolder}/Taskfile.yml\n optional: true\n"; - $marker = ' # ── END MODULES'; - - if (str_contains($content, "taskfile: ./modules/{$this->moduleFolder}/Taskfile.yml")) { - return; - } - - $markerPosition = strpos($content, $marker); - - if ($markerPosition === false) { - return; - } - - file_put_contents($taskfile, substr($content, 0, $markerPosition).$entry.substr($content, $markerPosition)); - } - - private function deleteDir(string $path): void - { - if ($this->fs->exists($path)) { - $this->fs->remove($path); - } - } - - private function copyDir(string $source, string $target): void - { - if ($this->fs->exists($source)) { - $this->fs->mirror($source, $target); - } - } -} diff --git a/app/Console/Commands/SauceBase/SeedModulesCommand.php b/app/Console/Commands/SauceBase/SeedModulesCommand.php deleted file mode 100644 index 74f5288e..00000000 --- a/app/Console/Commands/SauceBase/SeedModulesCommand.php +++ /dev/null @@ -1,47 +0,0 @@ -option('module') !== null - ? strtolower($this->option('module')) - : null; - - if ($filter !== null) { - $module = $registry->modules()->first(fn ($m) => strtolower($m->name) === $filter); - - if ($module === null) { - $this->components->error("Module \"{$filter}\" not found."); - - return self::FAILURE; - } - - $modules = collect([$module]); - } else { - $modules = $registry->modules(); - } - - foreach ($modules as $module) { - $seeder = 'Modules\\'.Str::studly($module->name).'\\Database\\Seeders\\DatabaseSeeder'; - - if (! class_exists($seeder)) { - continue; - } - - $this->components->task($module->name, fn () => $this->call('db:seed', ['--module' => strtolower($module->name)]) === self::SUCCESS); - } - - return self::SUCCESS; - } -} diff --git a/app/Facades/Navigation.php b/app/Facades/Navigation.php deleted file mode 100644 index c07680d1..00000000 --- a/app/Facades/Navigation.php +++ /dev/null @@ -1,16 +0,0 @@ -columns(1)->components([ - Section::make(__('Application Settings')) - ->description(__('Configure the platform identity.')) - ->icon(Heroicon::OutlinedInformationCircle) - ->iconColor('info') - ->schema([ - TextInput::make('site_name') - ->label(__('Site name')) - ->required() - ->maxLength(255), - TextInput::make('site_tagline') - ->label(__('Site tagline')) - ->helperText(__('Shown as the home page title, before the site name.')) - ->maxLength(60), - Textarea::make('site_description') - ->label(__('Site description')) - ->maxLength(500) - ->columnSpanFull(), - FileUpload::make('site_icon') - ->label(__('Site icon')) - ->extraAttributes(['data-testid' => 'admin-site-icon']) - ->image() - ->avatar() - ->imageEditor() - ->disk('public') - ->directory('site-branding') - ->visibility('public') - ->maxSize(1024) - ->helperText(__('Square. Used where only a mark fits, such as a collapsed sidebar.')), - FileUpload::make('site_logo') - ->label(__('Site logo')) - ->extraAttributes(['data-testid' => 'admin-site-logo']) - ->image() - ->disk('public') - ->directory('site-branding') - ->visibility('public') - ->maxSize(1024) - ->helperText(__('Wordmark. Used where there is room to show the site name.')), - Toggle::make('prefer_logo') - ->label(__('Prefer logo')) - ->extraAttributes(['data-testid' => 'admin-prefer-logo']) - ->helperText(__('Use the site logo instead of the icon when both are available.')), - ]) - ->columns(2), - ]); - } -} diff --git a/app/Filament/Admin/Pages/LocalizationSettings.php b/app/Filament/Admin/Pages/LocalizationSettings.php deleted file mode 100644 index 9acc5b7d..00000000 --- a/app/Filament/Admin/Pages/LocalizationSettings.php +++ /dev/null @@ -1,65 +0,0 @@ -columns(1)->components([ - Section::make(__('Languages')) - ->description(__('Choose which languages visitors can switch between. With only one language on, the language selector is hidden.')) - ->icon(Heroicon::OutlinedLanguage) - ->iconColor('info') - ->schema([ - CheckboxList::make('enabled_locales') - ->label(__('Available languages')) - ->extraAttributes(['data-testid' => 'admin-enabled-locales']) - ->options(fn (): array => app(LocalizationSettingsData::class)->available()) - ->helperText(__('Only languages with translation files installed are listed.')) - ->required() - ->minItems(1) - ->live() - ->columns(2) - ->columnSpanFull(), - Select::make('default_locale') - ->label(__('Default language')) - ->extraAttributes(['data-testid' => 'admin-default-locale']) - ->options(fn (Get $get): array => array_intersect_key( - app(LocalizationSettingsData::class)->available(), - array_flip((array) $get('enabled_locales')), - )) - ->helperText(__('Used until a visitor picks a language of their own.')) - ->required() - ->native(false) - ->columnSpanFull(), - ]), - ]); - } -} diff --git a/app/Filament/ModulePlugin.php b/app/Filament/ModulePlugin.php deleted file mode 100644 index 659f336b..00000000 --- a/app/Filament/ModulePlugin.php +++ /dev/null @@ -1,75 +0,0 @@ -getModuleName(), 'src'); - $baseNamespace = 'Modules\\'.$this->getModuleName(); - - $useClusters = config('filament.modules.clusters.enabled', false); - - $panel->discoverPages( - in: $appPath.DIRECTORY_SEPARATOR.'Filament'.DIRECTORY_SEPARATOR.'Pages', - for: $baseNamespace.'\\Filament\\Pages' - ); - $panel->discoverResources( - in: $appPath.DIRECTORY_SEPARATOR.'Filament'.DIRECTORY_SEPARATOR.'Resources', - for: $baseNamespace.'\\Filament\\Resources' - ); - $panel->discoverWidgets( - in: $appPath.DIRECTORY_SEPARATOR.'Filament'.DIRECTORY_SEPARATOR.'Widgets', - for: $baseNamespace.'\\Filament\\Widgets' - ); - - $panel->discoverLivewireComponents( - in: $appPath.DIRECTORY_SEPARATOR.'Livewire', - for: $baseNamespace.'\\Livewire' - ); - - if ($useClusters) { - $panel->discoverClusters( - in: $appPath.DIRECTORY_SEPARATOR.'Filament'.DIRECTORY_SEPARATOR.'Clusters', - for: $baseNamespace.'\\Filament\\Clusters', - ); - } - - $this->afterRegister($panel); - } - - public static function make(): static - { - return app(static::class); - } - - public static function get(): static - { - /** @var static $plugin */ - $plugin = filament(app(static::class)->getId()); - - return $plugin; - } - - public function afterRegister(Panel $panel) - { - // override this to implement additional logic - } - - /** - * `Filament\Contracts\Plugin` requires this, and most modules have nothing to put in - * it — it is where navigation groups get registered, and a module with a single - * top-level page needs no group. Defaulting it here means dropping a group is a - * deletion rather than a swap for an empty method. - */ - public function boot(Panel $panel): void - { - // override this to implement additional logic - } -} diff --git a/app/Filament/ModulesPlugin.php b/app/Filament/ModulesPlugin.php deleted file mode 100644 index 3b6b3683..00000000 --- a/app/Filament/ModulesPlugin.php +++ /dev/null @@ -1,85 +0,0 @@ -topNavigation($useTopNavigation); - - $plugins = $this->getModulePlugins(); - - // Register each module plugin - foreach ($plugins as $pluginClass) { - $panel->plugin($pluginClass::make()); - } - } - - public function boot(Panel $panel): void - { - $plugins = $this->getModulePlugins(); - - // Collect custom navigation items from module plugins - $navigationItems = []; - foreach ($plugins as $pluginClass) { - $plugin = $pluginClass::make(); - - // Check if the plugin has getNavigationItems method - if (method_exists($plugin, 'getNavigationItems')) { - $items = $plugin->getNavigationItems(); - if (! empty($items)) { - $navigationItems = array_merge($navigationItems, $items); - } - } - } - - // Register collected custom navigation items - if (! empty($navigationItems)) { - $panel->navigationItems($navigationItems); - } - } - - public static function make(): static - { - return app(static::class); - } - - public static function get(): static - { - /** @var static $plugin */ - $plugin = filament(app(static::class)->getId()); - - return $plugin; - } - - protected function getModulePlugins(): array - { - $modules = app(ModuleRegistry::class)->modules(); - - return $modules - ->map(function ($module) { - $moduleName = Str::studly($module->name); - $pluginClass = "Modules\\{$moduleName}\\Filament\\{$moduleName}Plugin"; - - return class_exists($pluginClass) ? $pluginClass : null; - }) - ->filter() - ->sortBy(fn (string $class) => method_exists($class, 'getNavigationGroupSort') - ? $class::getNavigationGroupSort() - : PHP_INT_MAX - ) - ->toArray(); - } -} diff --git a/app/Filament/Pages/SettingsPage.php b/app/Filament/Pages/SettingsPage.php deleted file mode 100644 index 15932f50..00000000 --- a/app/Filament/Pages/SettingsPage.php +++ /dev/null @@ -1,35 +0,0 @@ - $message, - 'type' => $type, - 'description' => $description, - 'action' => $action, - 'duration' => $duration, - 'position' => $position, - ], fn ($value) => $value !== null); - } - - /** - * Show a toast notification. - */ - public static function show( - string $message, - string $type = self::TYPE_SUCCESS, - ?string $description = null, - ?array $action = null, - ?int $duration = null, - ?string $position = null - ): void { - $toast = self::payload($message, $type, $description, $action, $duration, $position); - - // Inertia request (XHR) - if (request()->header('X-Inertia')) { - Inertia::flash('toast', $toast); - - return; - } - - // Non-Inertia request (OAuth / full page redirect) - Session::put('toast', $toast); - } - - public static function default( - string $message, - ?string $description = null, - ?array $action = null, - ?int $duration = null, - ?string $position = null - ): void { - self::show($message, self::TYPE_DEFAULT, $description, $action, $duration, $position); - } - - public static function success( - string $message, - ?string $description = null, - ?array $action = null, - ?int $duration = null, - ?string $position = null - ): void { - self::show($message, self::TYPE_SUCCESS, $description, $action, $duration, $position); - } - - public static function error( - string $message, - ?string $description = null, - ?array $action = null, - ?int $duration = null, - ?string $position = null - ): void { - self::show($message, self::TYPE_ERROR, $description, $action, $duration, $position); - } - - public static function info( - string $message, - ?string $description = null, - ?array $action = null, - ?int $duration = null, - ?string $position = null - ): void { - self::show($message, self::TYPE_INFO, $description, $action, $duration, $position); - } - - public static function warning( - string $message, - ?string $description = null, - ?array $action = null, - ?int $duration = null, - ?string $position = null - ): void { - self::show($message, self::TYPE_WARNING, $description, $action, $duration, $position); - } - - public static function loading( - string $message, - ?string $description = null, - ?array $action = null, - ?int $duration = null, - ?string $position = null - ): void { - self::show($message, self::TYPE_LOADING, $description, $action, $duration, $position); - } -} diff --git a/app/Helpers/helpers.php b/app/Helpers/helpers.php deleted file mode 100644 index e60c25a7..00000000 --- a/app/Helpers/helpers.php +++ /dev/null @@ -1,10 +0,0 @@ -enabled()); - - if (! in_array($locale, $enabledLocales, true)) { - return new JsonResponse(['error' => 'Invalid locale'], 400); - } - - App::setLocale($locale); - Session::put('locale', $locale); - - if ($user = $request->user()) { - $user->locale = $locale; - $user->save(); - } - - return new JsonResponse(['locale' => App::getLocale()]); - } -} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php deleted file mode 100644 index 912e980f..00000000 --- a/app/Http/Controllers/SettingsController.php +++ /dev/null @@ -1,60 +0,0 @@ -` fragment rather than by - * navigating here, so it can sit over whatever page the user is already on. - * A fragment never reaches the server, so the front-end mirrors it onto a - * `section` query parameter to say which panel it needs data for. - */ - public function __invoke(Request $request, SectionRegistry $registry): Modal - { - $sections = $registry->all(); - $requested = $request->string('section')->toString(); - - $active = $sections->first( - fn (SettingsSection $section): bool => $section->slug() === $requested, - ) ?? $sections->first(); - - return Inertia::modal('Settings', [ - 'activeSection' => $active?->slug(), - 'sectionProps' => $this->resolveProps($sections, $active), - ]); - } - - /** - * Collect the props each section contributes. - * - * Only the section being shown is resolved eagerly; the rest stay optional, so - * switching sections costs one partial reload rather than every section paying - * for every other section's queries each time the modal opens. - * - * Matched on slug rather than object identity: sections are resolved fresh from - * the container, so two lookups of the same section are two instances. - * - * @param Collection $sections - * @return array - */ - private function resolveProps(Collection $sections, ?SettingsSection $active): array - { - return $sections - ->mapWithKeys(fn (SettingsSection $section): array => [ - $section->slug() => $section->slug() === $active?->slug() - ? $section->props() - : Inertia::optional($section->props(...)), - ]) - ->all(); - } -} diff --git a/app/Http/Middleware/HandleAppearance.php b/app/Http/Middleware/HandleAppearance.php deleted file mode 100644 index 2df3d5fc..00000000 --- a/app/Http/Middleware/HandleAppearance.php +++ /dev/null @@ -1,19 +0,0 @@ -cookie('appearance') ?? 'system'; - View::share('appearance', in_array($appearance, ['light', 'dark', 'system']) ? $appearance : 'system'); - - return $next($request); - } -} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php deleted file mode 100644 index 333605b7..00000000 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ /dev/null @@ -1,107 +0,0 @@ -withSSR() or explicitly disable using ->withoutSSR() - * - * For Octane compatibility, SSR preferences are stored in request attributes - * to avoid race conditions. The macros set both the attribute and config. - */ - public function handle(Request $request, Closure $next): Response - { - // Disable SSR by default for this request - // Store in request attributes (thread-safe for Octane) - $request->attributes->set('inertia.ssr', false); - Config::set('inertia.ssr.enabled', false); - - // Process request - controller may override SSR via macros - return parent::handle($request, $next); - } - - /** - * Define the props that are shared by default. - * - * @return array - */ - public function share(Request $request): array - { - return array_merge(parent::share($request), [ - 'locale' => app()->getLocale(), - 'modules' => fn () => app(ModuleRegistry::class)->modules() - ->mapWithKeys(fn ($module) => [$module->name => $module->name]) - ->all(), - 'navigation' => fn () => app(Navigation::class)->treeGrouped(), - - // The settings modal's sidebar: discovered sections, not a nav group. - 'settings.sections' => fn () => app(SectionRegistry::class)->forFrontend(), - 'breadcrumbs' => fn () => $this->getBreadcrumbs(), - 'toast' => fn () => $request->session()->pull('toast'), - 'settings.general' => function (): array { - $settings = app(GeneralSettings::class); - - return [ - ...$settings->toCollection()->only([ - 'site_name', - 'site_tagline', - 'site_description', - 'prefer_logo', - ])->all(), - 'site_icon' => $settings->siteIconUrl(), - 'site_logo' => $settings->siteLogoUrl(), - ]; - }, - // Ziggy data is computed lazily so it can be skipped on partial reloads - 'ziggy' => fn () => [ - ...(new Ziggy)->toArray(), - 'location' => $request->url(), - ], - ]); - } - - /** - * Get breadcrumbs for the current request. - * Uses diglactic/laravel-breadcrumbs if available, falls back to Spatie Navigation. - */ - protected function getBreadcrumbs(): array - { - $routeName = request()->route()?->getName(); - - // Try to use laravel-breadcrumbs package - if ($routeName && Breadcrumbs::exists($routeName)) { - try { - $breadcrumbs = Breadcrumbs::generate($routeName, ...request()->route()->parameters()); - - // Convert to array format compatible with frontend - return collect($breadcrumbs)->map(fn (\stdClass $crumb) => [ - 'title' => 'breadcrumbs.'.$crumb->title, - 'url' => $crumb->url, - 'attributes' => $crumb->data ?? [], - ])->toArray(); - } catch (\Exception $e) { - report($e); - // Fall through to Spatie Navigation on error - } - } - - // Fallback to Spatie Navigation - return app(Navigation::class)->breadcrumbs(); - } -} diff --git a/app/Http/Middleware/HandleLocalization.php b/app/Http/Middleware/HandleLocalization.php deleted file mode 100644 index 3783d79f..00000000 --- a/app/Http/Middleware/HandleLocalization.php +++ /dev/null @@ -1,57 +0,0 @@ -enabled(); - - Inertia::share('locales', $enabledLocales); - - $locale = $request->user()->locale ?? Session::get('locale'); - - App::setLocale( - is_string($locale) && array_key_exists($locale, $enabledLocales) - ? $locale - : $this->defaultLocale($settings, $enabledLocales) - ); - - return $next($request); - } - - /** - * The locale to fall back on, guaranteed to be one the application still offers. - * - * @param array $enabledLocales - */ - private function defaultLocale(LocalizationSettings $settings, array $enabledLocales): string - { - if (array_key_exists($settings->default_locale, $enabledLocales)) { - return $settings->default_locale; - } - - return (string) array_key_first($enabledLocales); - } -} diff --git a/app/Http/Middleware/SecureHeaders.php b/app/Http/Middleware/SecureHeaders.php deleted file mode 100644 index 6befdf2d..00000000 --- a/app/Http/Middleware/SecureHeaders.php +++ /dev/null @@ -1,19 +0,0 @@ -withHeaders([ - 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains', - 'Content-Security-Policy' => 'upgrade-insecure-requests', - 'X-Content-Type-Options' => 'nosniff', - ]); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index e01f9333..bde1bf2e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,13 +3,13 @@ namespace App\Models; use App\Enums\Role; -use App\Settings\LocalizationSettings; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Saucebase\Core\Localization\LocalizationSettings; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; diff --git a/app/Navigation/Section.php b/app/Navigation/Section.php deleted file mode 100644 index f89dc5b0..00000000 --- a/app/Navigation/Section.php +++ /dev/null @@ -1,29 +0,0 @@ -attributes(['url_resolver' => $url]); - } - - if ($attributes) { - $section->attributes($attributes); - } - - $this->children[] = $section; - - return $this; - } -} diff --git a/app/Notifications/PasswordChangedNotification.php b/app/Notifications/PasswordChangedNotification.php deleted file mode 100644 index 87788b85..00000000 --- a/app/Notifications/PasswordChangedNotification.php +++ /dev/null @@ -1,66 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $changedAt = now()->isoFormat('LLL'); - - $mail = (new MailMessage) - ->subject(__('Password Changed Successfully')) - ->greeting(__('Hello :name,', ['name' => $notifiable->name])) - ->line(__('Your password was successfully changed.')) - ->line(__('Change time: :time', ['time' => $changedAt])) - ->line(__('If you did not make this change, please contact us immediately.')); - - // The auth module sends this too, and auth installs without settings — so the - // button disappears rather than throwing RouteNotFoundException. - if (Route::has('settings.profile')) { - $mail->action(__('View Profile'), route('settings.profile')); - } - - return $mail->line(__('Thank you for using our application!')); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - 'changed_at' => now()->toIso8601String(), - ]; - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a48f1a3c..452e6b65 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,103 +2,23 @@ namespace App\Providers; -use App\Http\Middleware\SecureHeaders; -use Filament\Forms\Components\Toggle; -use Filament\Tables\Columns\ToggleColumn; -use Illuminate\Foundation\Console\AboutCommand; -use Illuminate\Foundation\Events\LocaleUpdated; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; -use InterNACHI\Modular\Support\ModuleRegistry; class AppServiceProvider extends ServiceProvider { /** - * Bootstrap any application services. + * Register any application services. */ - public function boot(): void + public function register(): void { - $this->configureSecureUrls(); - $this->configureFilamentDefaults(); - $this->addCommandAboutInfo(); - $this->keepDatesInTheApplicationLanguage(); + // } /** - * Teach Carbon which language the application is currently speaking. - * - * Laravel does not do this itself: `App::setLocale()` moves the translator and leaves - * Carbon behind, so a date rendered with `isoFormat()` keeps its English month names - * in a Portuguese email. Listening for the event rather than setting it in middleware - * covers the paths a request never touches — queued mail and notifications, which set - * the locale themselves from the recipient's `preferredLocale()`. - */ - private function keepDatesInTheApplicationLanguage(): void - { - Carbon::setLocale($this->app->getLocale()); - - Event::listen(LocaleUpdated::class, function (LocaleUpdated $event): void { - Carbon::setLocale($event->locale); - }); - } - - /** - * How Filament components look before anybody asks. - * - * A toggle that is on is reporting a state, not offering the primary action on the - * page, and `primary` is the brand colour every button already wears. Green says - * "enabled" without competing with them. - * - * `configureUsing` runs at `make()`, so it is a default rather than an override: any - * component that names its own `onColor` still wins. Registered here rather than in a - * panel provider because these closures are recorded against the component class and - * apply to every panel regardless of where they are declared — putting them in one - * panel would only disguise that. + * Bootstrap any application services. */ - protected function configureFilamentDefaults(): void - { - Toggle::configureUsing(fn (Toggle $toggle) => $toggle->onColor('success')); - - ToggleColumn::configureUsing(fn (ToggleColumn $column) => $column->onColor('success')); - } - - protected function configureSecureUrls(): void - { - // Determine if HTTPS should be enforced - $enforceHttps = $this->app->environment(['production', 'staging']) - && ! $this->app->runningUnitTests(); - - // For local development with SSL setup - $localHttps = $this->app->environment('local') - && config('app.url') - && str_starts_with(config('app.url'), 'https://') - && ! $this->app->runningUnitTests(); - - $useHttps = $enforceHttps || $localHttps; - - // Force HTTPS for all generated URLs - URL::forceHttps($useHttps); - - // Ensure proper server variable is set - if ($useHttps) { - $this->app['request']->server->set('HTTPS', 'on'); - } - - // Set up global middleware for security headers in production/staging - if ($enforceHttps) { - $this->app['router']->pushMiddlewareToGroup('web', SecureHeaders::class); - } - } - - protected function addCommandAboutInfo(): void + public function boot(): void { - AboutCommand::add( - 'Modules', - app(ModuleRegistry::class)->modules()->mapWithKeys(fn ($module) => [ - $module->name => fn () => module_path($module->name), - ])->toArray(), - ); + // } } diff --git a/app/Providers/BreadcrumbServiceProvider.php b/app/Providers/BreadcrumbServiceProvider.php deleted file mode 100644 index 7d3bc7c5..00000000 --- a/app/Providers/BreadcrumbServiceProvider.php +++ /dev/null @@ -1,41 +0,0 @@ -loadModuleBreadcrumbs(); - } - - /** - * Load breadcrumbs from a file. - */ - protected function loadBreadcrumbsFrom(string $path): void - { - if (file_exists($path)) { - require_once $path; - } - } - - /** - * Load breadcrumbs from enabled modules. - */ - protected function loadModuleBreadcrumbs(): void - { - app(ModuleRegistry::class)->modules()->each(function ($module): void { - $this->loadBreadcrumbsFrom($module->path('routes/breadcrumbs.php')); - }); - } -} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 19522b80..c4df0b92 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,7 +2,6 @@ namespace App\Providers\Filament; -use App\Filament\ModulesPlugin; use Filament\Actions\Action; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; @@ -20,6 +19,9 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Openplain\FilamentShadcnTheme\Color; +use Saucebase\Core\Filament\Admin\GeneralSettings; +use Saucebase\Core\Filament\Admin\LocalizationSettings; +use Saucebase\Core\Filament\ModulesPlugin; class AdminPanelProvider extends PanelProvider { @@ -50,8 +52,14 @@ public function panel(Panel $panel): Panel ]) ->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\Filament\Admin\Resources') ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\Filament\Admin\Pages') + // Core's own settings pages are named rather than discovered: they live in + // vendor/, where Filament's directory scan does not reach. Removing one is a + // deleted line here, which is the escape hatch for anybody rebranding. + // TODO: Consider adding a mechanism to allow discovery of core settings pages if needed. ->pages([ Dashboard::class, + GeneralSettings::class, + LocalizationSettings::class, ]) ->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\Filament\Admin\Widgets') ->widgets([ diff --git a/app/Providers/MacroServiceProvider.php b/app/Providers/MacroServiceProvider.php deleted file mode 100644 index 2819c2c1..00000000 --- a/app/Providers/MacroServiceProvider.php +++ /dev/null @@ -1,60 +0,0 @@ -registerInertiaMacros(); - } - - /** - * Register Inertia-specific macros. - */ - protected function registerInertiaMacros(): void - { - // Add ->withSSR() method to Inertia Response for opt-in SSR - // Uses request attributes to avoid race conditions in concurrent environments (Octane) - Response::macro('withSSR', function () { - /** @var Response $this */ - // Store SSR preference in request attributes (thread-safe) - request()->attributes->set('inertia.ssr', true); - - // Also set config for immediate use (will be applied by middleware) - Config::set('inertia.ssr.enabled', true); - - return $this; - }); - - // Add ->withoutSSR() method to Inertia Response for opt-out SSR - // Uses request attributes to avoid race conditions in concurrent environments (Octane) - Response::macro('withoutSSR', function () { - /** @var Response $this */ - // Store SSR preference in request attributes (thread-safe) - request()->attributes->set('inertia.ssr', false); - - // Also set config for immediate use (will be applied by middleware) - Config::set('inertia.ssr.enabled', false); - - return $this; - }); - } -} diff --git a/app/Providers/ModalServiceProvider.php b/app/Providers/ModalServiceProvider.php deleted file mode 100644 index 200ea95a..00000000 --- a/app/Providers/ModalServiceProvider.php +++ /dev/null @@ -1,46 +0,0 @@ -app->make(SsrState::class); - - if ($state->page === []) { - return; - } - - $state->page['url'] = Str::start( - Str::after($request->fullUrl(), $request->getSchemeAndHttpHost()), - '/', - ); - }); - } -} diff --git a/app/Providers/ModuleServiceProvider.php b/app/Providers/ModuleServiceProvider.php deleted file mode 100644 index 5d9e8709..00000000 --- a/app/Providers/ModuleServiceProvider.php +++ /dev/null @@ -1,102 +0,0 @@ -moduleForClass(static::class)->name; - } - - /** - * Boot the application events. - */ - public function boot(): void - { - // TODO: remove once https://github.com/InterNACHI/modular/pull/117 is merged — - // internachi currently only discovers lang/ inside resources/, but modules keep it at root. - $this->registerTranslations(); - $this->registerPublicAssets(); - $this->shareInertiaData(); - } - - /** - * Register the service providers. - */ - public function register(): void - { - // Config is merged here rather than in boot() because every register() runs - // before any boot(): a provider that reads a module's config while booting — - // Filament resolving its panels, for one — would otherwise race the merge and - // see nothing. - $this->registerConfig(); - - foreach ($this->providers as $provider) { - $this->app->register($provider); - } - } - - protected function registerTranslations(): void - { - $langPath = module_path($this->moduleName(), 'lang'); - - if (is_dir($langPath)) { - $this->loadTranslationsFrom($langPath, $this->moduleName()); - } - } - - /** - * Register config. - */ - protected function registerConfig(): void - { - $configPath = 'config/config.php'; - - if (! file_exists(module_path($this->moduleName(), $configPath))) { - return; - } - - $this->publishes([module_path($this->moduleName(), $configPath) => config_path($this->moduleName().'.php')], $configPath); - $this->mergeConfigFrom(module_path($this->moduleName(), $configPath), $this->moduleName()); - } - - protected function registerPublicAssets(): void - { - $assetsPath = module_path($this->moduleName(), 'resources/assets'); - - if (is_dir($assetsPath)) { - $this->publishes( - [$assetsPath => public_path('modules/'.Str::kebab($this->moduleName()))], - 'module-assets' - ); - } - } - - /** - * Fully replace a config key with a module config file. - * - * Unlike mergeConfigFrom, this overwrites the entire key, - * avoiding array-merge issues with numeric-keyed arrays. - */ - protected function replaceConfig(string $path, string $key): void - { - $this->app->make('config')->set($key, require module_path($this->moduleName(), $path)); - } - - /** - * Share Inertia data globally. - */ - protected function shareInertiaData(): void {} -} diff --git a/app/Providers/NavigationServiceProvider.php b/app/Providers/NavigationServiceProvider.php deleted file mode 100644 index c68ec23b..00000000 --- a/app/Providers/NavigationServiceProvider.php +++ /dev/null @@ -1,38 +0,0 @@ -app->scoped(Navigation::class, function ($app) { - return new Navigation($app->make(ActiveUrlChecker::class)); - }); - - // Also bind Spatie's class to our implementation so existing DI still works - $this->app->alias(Navigation::class, \Spatie\Navigation\Navigation::class); - - // Register global alias for the facade - AliasLoader::getInstance(['Navigation' => NavigationFacade::class]); - } -} diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php deleted file mode 100644 index 9e63c003..00000000 --- a/app/Providers/SettingsServiceProvider.php +++ /dev/null @@ -1,64 +0,0 @@ -app->booting(function (): void { - $this->discoverModuleSettings(); - - $this->app->make(SettingsContainer::class) - ->clearCache() - ->registerBindings(); - }); - } - - protected function discoverModuleSettings(): void - { - $modules = $this->app->make(ModuleRegistry::class)->modules(); - - $settingsPaths = $modules - ->map(fn (ModuleConfig $module): string => $module->path('src/Settings')) - ->filter(fn (string $path): bool => is_dir($path)); - - $migrationPaths = $modules - ->map(fn (ModuleConfig $module): string => $module->path('database/settings')) - ->filter(fn (string $path): bool => is_dir($path)); - - config()->set( - 'settings.auto_discover_settings', - $this->normalizeUniquePaths([ - ...config('settings.auto_discover_settings', []), - ...$settingsPaths, - ]), - ); - - config()->set( - 'settings.migrations_paths', - $this->normalizeUniquePaths([ - ...config('settings.migrations_paths', []), - ...$migrationPaths, - ]), - ); - } - - /** - * @param array $paths - * @return array - */ - protected function normalizeUniquePaths(array $paths): array - { - return collect($paths) - ->map(fn (string $path): string => rtrim(str_replace('\\', '/', $path), '/')) - ->unique() - ->values() - ->all(); - } -} diff --git a/app/Services/FrontendConfig.php b/app/Services/FrontendConfig.php deleted file mode 100644 index ca4e2ac6..00000000 --- a/app/Services/FrontendConfig.php +++ /dev/null @@ -1,29 +0,0 @@ -|null */ - private ?array $data = null; - - public function getFramework(): ?string - { - return $this->load()['framework'] ?? null; - } - - public function isDev(): bool - { - return ($this->load()['dev'] ?? false) === true; - } - - /** @return array */ - private function load(): array - { - if ($this->data === null) { - $this->data = @json_decode((string) @file_get_contents(base_path('frontend.json')), true) ?? []; - } - - return $this->data; - } -} diff --git a/app/Services/Navigation.php b/app/Services/Navigation.php deleted file mode 100644 index 4c1c649a..00000000 --- a/app/Services/Navigation.php +++ /dev/null @@ -1,333 +0,0 @@ -loaded) { - $this->loaded = true; - $this->load(); - } - } - - /** - * Add a navigation item. - * - * Overrides Spatie's add() to create App\Navigation\Section instances - * instead of Spatie\Navigation\Section, keeping imports consistent. - */ - public function add(string $title = '', string|\Closure $url = '', ?callable $configure = null): self - { - $section = new Section($this, $title, $url instanceof \Closure ? '' : $url); - - if ($configure) { - $configure($section); - } - - // Set after configure so it cannot be overwritten by a $section->attributes([...]) call - if ($url instanceof \Closure) { - $section->attributes(['url_resolver' => $url]); - } - - $this->children[] = $section; - - return $this; - } - - /** - * Add a navigation item with a runtime condition check. - * - * Unlike addIf (which checks at registration time), addWhen stores a callable - * that is evaluated at render time, allowing for dynamic visibility based on - * runtime state (e.g., current user permissions, feature flags). - * - * @param callable $condition Callable that returns bool, evaluated at render time - * @param string $title Navigation item title - * @param string $url Navigation item URL - * @param callable|null $configure Optional callback to configure the Section - */ - public function addWhen(callable $condition, string $title = '', string|\Closure $url = '', ?callable $configure = null): self - { - $this->add($title, $url, function (Section $section) use ($condition, $configure) { - // Store the 'when' callable for runtime evaluation - $section->attributes([ - 'when' => $condition, - ]); - - // Apply additional configuration if provided - if ($configure) { - $configure($section); - } - }); - - return $this; - } - - /** - * Get all navigation items grouped by their 'group' attribute. - * - * Returns an associative array where keys are group names and values are - * arrays of MenuItem objects. Items are filtered by 'when' callables and - * transformed to the MenuItem format. - * - * This is more efficient than calling treeByGroup() multiple times, as it - * processes the entire navigation tree once and groups items automatically. - * - * @return array Associative array of grouped MenuItems, e.g.: - * [ - * 'main' => [...MenuItem[]], - * 'settings' => [...MenuItem[]], - * 'user' => [...MenuItem[]], - * ] - */ - public function tree(): array - { - $this->ensureLoaded(); - - return parent::tree(); - } - - public function breadcrumbs(): array - { - $this->ensureLoaded(); - - return parent::breadcrumbs(); - } - - public function treeGrouped(): array - { - $tree = $this->tree(); - - // Group items by their 'group' attribute - $grouped = []; - foreach ($tree as $item) { - $group = $item['attributes']['group'] ?? 'ungrouped'; - if (! isset($grouped[$group])) { - $grouped[$group] = []; - } - $grouped[$group][] = $item; - } - - // Filter and transform each group to MenuItem format - foreach ($grouped as $group => $items) { - $filtered = $this->filterCallable($items); - $grouped[$group] = $this->transformTree($filtered); - } - - return $grouped; - } - - /** - * Filter navigation items based on 'when' callable attribute. - * - * Items without a 'when' attribute are included by default. - * Items with a 'when' callable are included only if the callable returns true. - * - * @param array $tree Spatie navigation tree - * @return array Filtered navigation items - */ - protected function filterCallable(array $tree): array - { - $filtered = array_filter($tree, function ($item) { - $callable = $item['attributes']['when'] ?? null; - - // If no 'when' callable exists, include the item by default - if ($callable === null) { - return true; - } - - // If 'when' exists, include only if it's callable and returns true - return is_callable($callable) && $callable(); - }); - - // Recursively filter children - $filtered = array_map(function ($item) { - if (! empty($item['children'])) { - $item['children'] = $this->filterCallable($item['children']); - } - - return $item; - }, $filtered); - - return array_values($filtered); - } - - /** - * Transform Spatie navigation tree to MenuItem array. - * - * @param array $tree Spatie navigation tree - * @return array MenuItem array - */ - protected function transformTree(array $tree): array - { - // Sort by order attribute before transforming - $tree = $this->sortByOrder($tree); - - return array_map( - fn (array $item) => $this->transformItem($item), - $tree - ); - } - - /** - * Transform a single navigation item to MenuItem format. - * - * Converts Spatie's navigation item structure to a clean MenuItem object, - * filtering out internal attributes (when, group, order) and calculating - * the active state based on route matching. - * - * @param array $item Spatie navigation item - * @return array MenuItem with structure: - * [ - * 'title' => string, - * 'url' => string|null, - * 'active' => bool, - * 'icon' => string|null, // optional - * 'action' => string|null, // optional - * 'type' => string|null, // optional - * 'external' => bool|null, // optional - * 'children' => array|null, // optional - * ] - */ - protected function transformItem(array $item): array - { - $attributes = $item['attributes'] ?? []; - - // Resolve URL once — handles both static strings and Closures - $resolvedUrl = $this->resolveItemUrl($item); - - $menuItem = [ - 'title' => $item['title'], - 'active' => $this->isItemActiveForUrl($resolvedUrl), - 'slug' => $attributes['slug'] ?? Str::slug($item['title'], '-'), - ]; - - if ($resolvedUrl !== null) { - $menuItem['url'] = $resolvedUrl; - } - - // Add icon if specified - if (isset($attributes['icon'])) { - $menuItem['icon'] = $attributes['icon']; - } - - // Add optional attributes if they exist (internal attributes like 'when', 'group', 'order' are excluded) - $optionalFields = ['action', 'type', 'external', 'newPage', 'class', 'badge']; - foreach ($optionalFields as $field) { - if (isset($attributes[$field])) { - $menuItem[$field] = $attributes[$field]; - } - } - - // Transform children recursively - if (! empty($item['children'])) { - $menuItem['children'] = $this->transformTree($item['children']); - } - - return $menuItem; - } - - /** - * Check if a navigation item is active based on current URL. - * - * Uses exact matching only. Parent items should handle their own active state - * by checking if any of their children are active. - * - * @param array $item Spatie navigation item - * @return bool Whether the item is active - */ - protected function isItemActive(array $item): bool - { - return $this->isItemActiveForUrl($this->resolveItemUrl($item)); - } - - private function isItemActiveForUrl(?string $itemUrl): bool - { - if (! $itemUrl) { - return false; - } - - $currentUrl = request()->url(); - - $itemPath = rtrim(parse_url($itemUrl, PHP_URL_PATH), '/'); - $currentPath = rtrim(parse_url($currentUrl, PHP_URL_PATH), '/'); - - return $currentPath === $itemPath; - } - - private function resolveItemUrl(array $item): ?string - { - $resolver = $item['attributes']['url_resolver'] ?? null; - - if ($resolver instanceof \Closure) { - return $resolver(); - } - - $url = $item['url'] ?? null; - - return ($url !== '' && $url !== null) ? $url : null; - } - - /** - * Sort navigation items by order attribute. - * - * @param array $items Navigation items - * @return array Sorted items - */ - protected function sortByOrder(array $items): array - { - usort($items, function ($a, $b) { - $orderA = $a['attributes']['order'] ?? 999; - $orderB = $b['attributes']['order'] ?? 999; - - return $orderA <=> $orderB; - }); - - return $items; - } - - /** - * Load the navigation items from navigation.php files. - * - * Looks for navigation.php files in: - * - routes/navigation.php (core navigation) - * - modules/{ModuleName}/routes/navigation.php (module navigation) - * - * Only loads module navigation if the module is enabled. - * - * @return $this - */ - public function load(): self - { - // Load core navigation - $coreNavigationPath = base_path('routes/navigation.php'); - if (file_exists($coreNavigationPath)) { - require_once $coreNavigationPath; - } - - // Load navigation from all modules discovered by InterNACHI - app(ModuleRegistry::class)->modules()->each(function ($module): void { - $path = $module->path('routes/navigation.php'); - if (file_exists($path)) { - require_once $path; - } - }); - - return $this; - } -} diff --git a/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php deleted file mode 100644 index 8d90304e..00000000 --- a/app/Settings/GeneralSettings.php +++ /dev/null @@ -1,65 +0,0 @@ -publicFileUrl($this->site_icon); - } - - public function siteLogoUrl(): ?string - { - return $this->publicFileUrl($this->site_logo); - } - - public static function group(): string - { - return 'general'; - } - - private function publicFileUrl(?string $path): ?string - { - if (! $path) { - return null; - } - - return Str::startsWith($path, '/') || Str::isUrl($path) - ? $path - : Storage::disk('public')->url($path); - } -} diff --git a/app/Settings/LocalizationSettings.php b/app/Settings/LocalizationSettings.php deleted file mode 100644 index bbcc3599..00000000 --- a/app/Settings/LocalizationSettings.php +++ /dev/null @@ -1,113 +0,0 @@ - */ - public array $enabled_locales; - - public string $default_locale; - - public static function group(): string - { - return 'localization'; - } - - /** - * Every locale with translation files behind it, mapped to its display name. - * - * Discovered rather than configured, so dropping `lang/es/` into the application — or - * installing a module that ships one — offers Spanish without a code change. - * - * @return array - */ - public function available(): array - { - $paths = [ - ...glob(lang_path('*'), GLOB_ONLYDIR) ?: [], - ...glob(base_path('modules/*/lang/*'), GLOB_ONLYDIR) ?: [], - ]; - - $locales = []; - - foreach (array_unique(array_map('basename', $paths)) as $code) { - // `lang/vendor/` holds published package translations grouped by package, not - // a locale of its own. - if ($code === 'vendor') { - continue; - } - - $locales[$code] = $this->displayName($code); - } - - asort($locales); - - return $locales; - } - - /** - * The subset the admin offers, mapped to display names. - * - * Never empty: an application with no language at all would render raw translation - * keys, so a setting that resolves to nothing falls back to the configured locale. - * - * @return array - */ - public function enabled(): array - { - $available = $this->available(); - - $enabled = array_intersect_key($available, array_flip($this->enabled_locales)); - - if ($enabled !== []) { - return $enabled; - } - - $fallback = (string) config('app.locale', 'en'); - - return [$fallback => $available[$fallback] ?? $this->displayName($fallback)]; - } - - /** - * What a locale is called. - * - * Config first, because a language selector names a language in that language - * ("Português", not "Portuguese (Brazil)") and intl does not produce that phrasing. - * intl is only a fallback for a locale nobody has named, and `ext-intl` is not a - * declared requirement, so its absence has to be survivable. - */ - private function displayName(string $code): string - { - $configured = config('app.available_locales', [])[$code] ?? null; - - if (is_string($configured)) { - return $configured; - } - - if (! extension_loaded('intl')) { - return $code; - } - - return Str::ucfirst(Locale::getDisplayName($code, $code) ?: $code); - } -} diff --git a/app/Settings/SectionRegistry.php b/app/Settings/SectionRegistry.php deleted file mode 100644 index 425fd7f7..00000000 --- a/app/Settings/SectionRegistry.php +++ /dev/null @@ -1,135 +0,0 @@ ->|null */ - private ?array $discovered = null; - - public function __construct(private readonly ModuleRegistry $modules) {} - - /** - * Every section that applies to this request, in sidebar order. - * - * @return Collection - */ - public function all(): Collection - { - return collect($this->discover()) - ->map(fn (string $class): SettingsSection => app($class)) - ->filter(fn (SettingsSection $section): bool => $section->visible()) - ->sortBy(fn (SettingsSection $section): int => $section->order()) - ->values(); - } - - /** - * The sidebar's view of the sections: what the front-end needs to list them. - * - * @return array - */ - public function forFrontend(): array - { - return $this->all() - ->map(fn (SettingsSection $section): array => [ - 'slug' => $section->slug(), - 'title' => $section->title(), - 'icon' => $section->icon(), - 'component' => $section->component(), - ]) - ->all(); - } - - /** - * Scan core and every module for section classes. - * - * Cached per instance rather than per request: the filesystem walk is the - * expensive part and the answer cannot change within a request. - * - * @return array> - */ - private function discover(): array - { - if ($this->discovered !== null) { - return $this->discovered; - } - - $directories = collect([app_path('Settings')]) - ->merge($this->modules->modules()->map( - fn (ModuleConfig $module): string => $module->path('src/Settings'), - )) - ->filter(fn (string $path): bool => is_dir($path)) - ->all(); - - if ($directories === []) { - return $this->discovered = []; - } - - return $this->discovered = collect((new Finder)->files()->name('*.php')->in($directories)) - ->map(fn (SplFileInfo $file): string => $this->classNameFor($file)) - ->filter(function (string $class): bool { - try { - return is_subclass_of($class, SettingsSection::class) - && (new ReflectionClass($class))->isInstantiable(); - } catch (Throwable) { - return false; - } - }) - ->values() - ->all(); - } - - /** - * PSR-4 means the path under the autoloader's root maps onto the namespace, - * so the class name can be read off the file path without loading the file. - */ - private function classNameFor(SplFileInfo $file): string - { - $path = str_replace(['/', '\\'], '\\', $file->getRelativePathname()); - $class = substr($path, 0, -strlen('.php')); - - foreach ($this->namespaceRoots() as $directory => $namespace) { - if (str_starts_with($file->getPath(), $directory)) { - return $namespace.'\\'.$class; - } - } - - return $class; - } - - /** - * @return array - */ - private function namespaceRoots(): array - { - $roots = [app_path('Settings') => 'App\\Settings']; - - foreach ($this->modules->modules() as $module) { - $roots[$module->path('src/Settings')] = $module->namespace().'Settings'; - } - - return $roots; - } -} diff --git a/app/Settings/SettingsSection.php b/app/Settings/SettingsSection.php deleted file mode 100644 index 72ee5cae..00000000 --- a/app/Settings/SettingsSection.php +++ /dev/null @@ -1,71 +0,0 @@ -` fragment. */ - abstract public function slug(): string; - - /** Shown in the modal's sidebar. Translate here, not in the registry. */ - abstract public function title(): string; - - /** The front-end component the panel renders, e.g. `Billing::SettingsBilling`. */ - abstract public function component(): string; - - /** - * The panel's data. - * - * Only the section being shown is called; the rest are wrapped in - * `Inertia::optional()` and resolved when the user switches to them. - * - * @return array - */ - abstract public function props(): array; - - /** Registered icon name for the sidebar entry. */ - public function icon(): ?string - { - return null; - } - - /** Lower sorts first. Leaves room between the defaults for later sections. */ - public function order(): int - { - return 100; - } - - /** - * Whether this section applies to the current request. - * - * Evaluated per request, so it can depend on the signed-in user or the host - * being served — a workspace section has nothing to show on a central host. - */ - public function visible(): bool - { - return true; - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index 6f7621f5..6fd28804 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,14 +1,14 @@ [ diff --git a/database/settings/0001_01_01_000010_create_general_settings.php b/database/settings/0001_01_01_000010_create_general_settings.php deleted file mode 100644 index abf55963..00000000 --- a/database/settings/0001_01_01_000010_create_general_settings.php +++ /dev/null @@ -1,33 +0,0 @@ -migrator->exists('general.site_name')) { - $this->migrator->add('general.site_name', config('app.name', 'Saucebase')); - } - - if (! $this->migrator->exists('general.site_tagline')) { - $this->migrator->add('general.site_tagline', null); - } - - if (! $this->migrator->exists('general.site_description')) { - $this->migrator->add('general.site_description', null); - } - - if (! $this->migrator->exists('general.site_icon')) { - $this->migrator->add('general.site_icon', null); - } - - if (! $this->migrator->exists('general.site_logo')) { - $this->migrator->add('general.site_logo', null); - } - - if (! $this->migrator->exists('general.prefer_logo')) { - $this->migrator->add('general.prefer_logo', false); - } - } -}; diff --git a/database/settings/0001_01_01_000012_create_localization_settings.php b/database/settings/0001_01_01_000012_create_localization_settings.php deleted file mode 100644 index 2f2a9acf..00000000 --- a/database/settings/0001_01_01_000012_create_localization_settings.php +++ /dev/null @@ -1,21 +0,0 @@ -migrator->exists('localization.enabled_locales')) { - $this->migrator->add( - 'localization.enabled_locales', - array_keys(config('app.available_locales', ['en' => 'English'])), - ); - } - - if (! $this->migrator->exists('localization.default_locale')) { - $this->migrator->add('localization.default_locale', config('app.locale', 'en')); - } - } -}; diff --git a/lang/pt_BR.json b/lang/pt_BR.json index 266abd6c..d88b051a 100644 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -20,24 +20,15 @@ "7. Limitation of Liability": "7. Limitação de Responsabilidade", "8. Changes to Terms": "8. Alterações nos Termos", "9. Contact Us": "9. Fale Conosco", - ":count minute|:count minutes": ":count minuto|:count minutos", - "Account settings pages for managing profile info, avatar, password, and connected social accounts.": "Páginas de configurações da conta para gerenciar informações de perfil, avatar, senha e contas sociais conectadas.", + "Added": "Added", "Admin Panel": "Painel de Administração", "Administrator": "Administrador", - "Already registered?": "Já possui uma conta?", "An error occurred.": "Ocorreu um erro.", "Analytics": "Análises", "Announcements": "Avisos", "Any Stack": "Qualquer Stack", - "Are you sure you want to disconnect this social account? The user may lose access if this is their only login method without a password.": "Tem certeza que deseja desconectar esta conta social? O usuário pode perder o acesso se este for o único método de login sem uma senha.", - "Are you sure you want to log out? You will need to sign in again.": "Are you sure you want to log out? You will need to sign in again.", "Audience Targeting": "Segmentação de Público", "Auth": "Auth", - "auth::auth.magic-link-expired": "auth::auth.magic-link-expired", - "auth::auth.magic-link-sent": "auth::auth.magic-link-sent", - "Authentication": "Autenticação", - "Avatar Upload": "Upload de Avatar", - "Back to login": "Voltar para o login", "Baked CSS": "CSS Compilado", "Banner": "Banner", "Billing": "Faturamento", @@ -47,48 +38,29 @@ "by :name": "por :name", "By accessing or using Saucebase, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use our service.": "Ao acessar ou usar o Saucebase, você concorda em estar vinculado a estes Termos de Serviço. Se não concordar com estes termos, por favor não utilize nosso serviço.", "Cancel": "Cancelar", - "Cannot disconnect": "Não é possível desconectar", - "Cannot impersonate yourself": "Não é possível se representar", "Categories": "Categorias", "Certain features of Saucebase may require payment. You agree to pay all applicable fees as described on our pricing page. All payments are non-refundable unless otherwise specified or required by law.": "Certos recursos do Saucebase podem exigir pagamento. Você concorda em pagar todas as taxas aplicáveis conforme descrito em nossa página de preços. Todos os pagamentos são não reembolsáveis, salvo indicação em contrário ou exigência legal.", "Checkout": "Checkout", - "Click the button below to log in. This link expires in :expiry and can only be used once.": "Clique no botão abaixo para entrar. Este link expira em :expiry e só pode ser usado uma vez.", "Close": "Fechar", "Close mobile menu": "Fechar menu mobile", "Coming Soon": "Em Breve", "Complete authentication system with login, registration, magic link (passwordless), password reset, email verification, and OAuth integration (Google, GitHub).": "Sistema completo de autenticação com login, registro, link mágico (sem senha), redefinição de senha, verificação de e-mail e integração OAuth (Google, GitHub).", "Confirm": "Confirmar", - "Confirm Password": "Confirmar Senha", - "Confirm your new password": "Confirme sua nova senha", - "Connect with :Provider": "Conectar com :Provider", "Connect your app with third-party services like Slack, Zapier, and more through a unified integration layer.": "Conecte seu aplicativo a serviços de terceiros como Slack, Zapier e mais através de uma camada de integração unificada.", - "Connected": "Conectado", - "Connected Accounts": "Contas Conectadas", "Copied to clipboard": "Copiado para a área de transferência", "Copy & Own": "Copie e Possua", "Crafted with": "Desenvolvido com", - "Create your account": "Crie sua conta", - "Created at": "Criado em", "Custom Connections": "Conexões Personalizadas", + "Custom Domains": "Custom Domains", "Custom Events": "Eventos Personalizados", "Dark & Light Mode": "Modo Escuro e Claro", "Dashboard": "Painel", + "Data Isolation": "Data Isolation", "Delivery Logs": "Registros de Entrega", - "Disconnect": "Desconectar", - "Disconnect :provider?": "Desconectar :provider?", "Dismissal": "Descarte", "Documentation": "Documentação", - "Don't have an account?": "Não tem uma conta?", "Email": "E-mail", - "Email address": "Endereço de e-mail", - "Email Password Reset Link": "Enviar Link de Redefinição de Senha", "Email Verification": "Verificação de E-mail", - "Email verified at": "E-mail verificado em", - "Enter your email": "Digite seu e-mail", - "Enter your email to receive a secure, one-time login link.": "Digite seu e-mail para receber um link de acesso seguro e único.", - "Enter your full name": "Digite seu nome completo", - "Enter your new password below": "Digite sua nova senha abaixo", - "Enter your password": "Digite sua senha", "Error": "Erro", "Event Triggers": "Gatilhos de Eventos", "Failed to copy": "Falha ao copiar", @@ -96,118 +68,80 @@ "Feature Requests": "Solicitações de Recursos", "FOCUS HERE": "FOQUE AQUI", "for developers": "para desenvolvedores", - "Forgot Password": "Esqueceu a Senha", - "Forgot your password?": "Esqueceu sua senha?", - "Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.": "Esqueceu sua senha? Sem problemas. Informe seu endereço de e-mail e enviaremos um link de redefinição de senha para que você possa escolher uma nova.", - "Free, open-source Laravel SaaS starter kit. Ships with auth, billing, admin panel, and a modular copy-and-own architecture.": "Kit inicial Laravel SaaS gratuito e de código aberto. Inclui autenticação, faturamento, painel administrativo e uma arquitetura modular copy-and-own.", "Full Ownership": "Propriedade Total", "Full-featured blog with posts, categories, cover images, SEO metadata, and a Filament admin panel for content management.": "Blog completo com postagens, categorias, imagens de capa, metadados SEO e um painel administrativo Filament para gerenciamento de conteúdo.", "Get Started": "Começar", "GitHub": "GitHub", "Go to Home": "Ir para o Início", - "Hello :name,": "Olá :name,", "Hide password": "Ocultar senha", - "If you did not request this link, you can safely ignore this email.": "Se você não solicitou este link, pode ignorar este e-mail com segurança.", "If you have any questions about these Terms of Service, please contact us at": "Se tiver alguma dúvida sobre estes Termos de Serviço, entre em contato conosco em", "If you have any questions about this Privacy Policy, please contact us at": "Se tiver alguma dúvida sobre esta Política de Privacidade, entre em contato conosco em", "Impersonation": "Representação", - "Impersonation alert": "Alerta de representação", - "Impersonator is not an administrador": "O representante não é um administrador", - "Impersonator not authenticated": "Representante não autenticado", "In-App": "No Aplicativo", "In-app and email notifications with templates, user preferences, and delivery tracking for every channel.": "Notificações no aplicativo e por e-mail com modelos, preferências do usuário e rastreamento de entrega para cada canal.", + "Installed": "Installed", "Integrations": "Integrações", "Language": "Idioma", "Language Selector": "Seletor de Idioma", - "Last activity at": "Última atividade em", - "Last Login": "Último Acesso", - "Last login at": "Último acesso em", "Last updated: January 1, 2026": "Última atualização: 1 de janeiro de 2026", - "Last used": "Último uso", "Learn how Saucebase collects, uses, and protects your personal information.": "Saiba como o Saucebase coleta, usa e protege suas informações pessoais.", "Let's get started": "Vamos começar", - "Log in": "Entrar", - "Log out": "Sair", - "Log Out": "Sair", "Login & Register": "Login e Cadastro", - "Login to your Saucebase account to continue": "Entre na sua conta Saucebase para continuar", - "Login with magic link": "Entrar com link mágico", "Logout": "Sair", "Magic Link": "Link Mágico", - "Magic Link Login": "Login com Link Mágico", - "Name": "Nome", - "Never": "Nunca", "NEW": "NOVO", - "No role assigned": "Nenhuma função atribuída", "Notifications": "Notificações", "Open mobile menu": "Abrir menu mobile", - "Or continue with email": "Ou continue com e-mail", "Pageviews": "Visualizações de Página", - "Password": "Senha", "Password Change": "Alteração de Senha", - "Password confirmation": "Confirmação de senha", "Posts": "Posts", "Preferences": "Preferências", "Privacy": "Privacidade", "Privacy First": "Privacidade em Primeiro Lugar", "Privacy Policy": "Política de Privacidade", "Profile": "Perfil", - "Profile Info": "Informações do Perfil", - "Provider": "Provedor", + "Profile & Avatar": "Profile & Avatar", "Public roadmap with feature requests, voting, moderation, six statuses, and a Filament admin panel.": "Roadmap público com solicitações de recursos, votação, moderação, seis status e um painel administrativo Filament.", "Read our Terms of Service to understand the rules and guidelines for using Saucebase.": "Leia nossos Termos de Serviço para entender as regras e diretrizes de uso do Saucebase.", "Read the Documentation": "Leia a Documentação", "Ready to build something amazing?": "Pronto para construir algo incrível?", - "Recent impersonated users": "Usuários representados recentemente", - "Register": "Cadastrar", - "Remember-me": "Lembrar-me", - "Resend Verification Email": "Reenviar E-mail de Verificação", - "Reset Password": "Redefinir Senha", "Retry Handling": "Tratamento de Novas Tentativas", "Roadmap": "Roadmap", - "Role": "Função", + "Roles & Invites": "Roles & Invites", "Saucebase is provided on an \"as is\" and \"as available\" basis without any warranties of any kind, either express or implied. We do not warrant that the service will be uninterrupted, error-free, or completely secure.": "O Saucebase é fornecido \"no estado em que se encontra\" e \"conforme disponível\", sem quaisquer garantias de qualquer tipo, expressas ou implícitas. Não garantimos que o serviço será ininterrupto, livre de erros ou completamente seguro.", "Scheduling": "Agendamento", - "Send Magic Link": "Enviar Link Mágico", + "Select a section": "Select a section", "Send reliable HTTP callbacks to external services when events occur in your app, with delivery logs and retry handling.": "Envie callbacks HTTP confiáveis para serviços externos quando eventos ocorrem no seu aplicativo, com registros de entrega e tratamento de novas tentativas.", "SEO Optimized": "Otimizado para SEO", "Settings": "Configurações", - "Show impersonation details": "Mostrar detalhes da representação", "Show password": "Mostrar senha", "Sign In": "Entrar", - "Sign up": "Cadastrar-se", - "Sign up for Saucebase to start building your SaaS": "Cadastre-se no Saucebase para começar a construir seu SaaS", "Single Command": "Comando Único", "Site-wide announcement banners with scheduling, audience targeting, and cookie-based dismissal, managed from the Filament admin panel.": "Banners de avisos em todo o site com agendamento, segmentação de público e descarte baseado em cookies, gerenciados pelo painel administrativo Filament.", "Slack": "Slack", - "Social account disconnected": "Conta social desconectada", - "Social Accounts": "Contas Sociais", "Social Login": "Login Social", "SOON": "EM BREVE", "Sorry, the page you are looking for could not be found.": "Desculpe, a página que você está procurando não foi encontrada.", "Sorry, we are doing some maintenance. Please check back soon.": "Desculpe, estamos realizando manutenção. Por favor, volte em breve.", "Sorry, you are forbidden from accessing this page.": "Desculpe, você não tem permissão para acessar esta página.", "Sorting": "Ordenação", - "Stop Impersonation": "Parar Representação", + "Subdomain-based workspaces with isolated data, member roles, email invitations, and joining by email domain.": "Subdomain-based workspaces with isolated data, member roles, email invitations, and joining by email domain.", "Subscriber": "Assinante", "Subscription management and payment processing via Stripe with checkout sessions, billing portal, invoices, and webhook processing.": "Gerenciamento de assinaturas e processamento de pagamentos via Stripe com sessões de checkout, portal de faturamento, faturas e processamento de webhooks.", "Subscriptions": "Assinaturas", "Templates": "Modelos", + "Tenancy": "Tenancy", "Terms": "Termos", "Terms of Service": "Termos de Serviço", - "Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.": "Obrigado por se cadastrar! Antes de começar, poderia verificar seu endereço de e-mail clicando no link que acabamos de enviar? Se não recebeu o e-mail, teremos prazer em enviar outro.", - "The :provider account has been disconnected successfully.": "A conta :provider foi desconectada com sucesso.", "The security of your data is important to us but remember that no method of transmission over the Internet or method of electronic storage is 100% secure. While we strive to use commercially acceptable means to protect your personal data, we cannot guarantee its absolute security.": "A segurança dos seus dados é importante para nós, mas lembre-se que nenhum método de transmissão pela Internet ou de armazenamento eletrônico é 100% seguro. Embora nos esforcemos para usar meios comercialmente aceitáveis para proteger seus dados pessoais, não podemos garantir sua segurança absoluta.", "The service and its original content, features, and functionality are and will remain the exclusive property of Saucebase and its licensors. Our trademarks and trade dress may not be used without our prior written consent.": "O serviço e seu conteúdo original, recursos e funcionalidades são e permanecerão propriedade exclusiva do Saucebase e seus licenciadores. Nossas marcas registradas e identidade visual não podem ser usadas sem nosso consentimento prévio por escrito.", "Theme": "Tema", "Themes": "Temas", "This module may require additional steps after installation, check the docs": "Este módulo pode exigir etapas adicionais após a instalação, verifique a documentação", - "This user has not connected any social accounts yet.": "Este usuário ainda não conectou nenhuma conta social.", "To the fullest extent permitted by law, Saucebase shall not be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of the service.": "Na máxima extensão permitida por lei, o Saucebase não será responsável por quaisquer danos indiretos, incidentais, especiais, consequenciais ou punitivos decorrentes do uso do serviço.", "Toggle theme": "Alternar tema", "Track pageviews, custom events, and user behavior with a privacy-friendly built-in dashboard — no third-party scripts.": "Rastreie visualizações de página, eventos personalizados e comportamento do usuário com um painel integrado voltado para privacidade — sem scripts de terceiros.", "Unified Layer": "Camada Unificada", - "Updated at": "Atualizado em", "User": "Usuário", "View Documentation": "Ver Documentação", "Visual Editor": "Editor Visual", @@ -220,14 +154,12 @@ "We use the information we collect to provide, maintain, and improve our services, process transactions, send you technical notices and support messages, and respond to your comments and questions.": "Usamos as informações coletadas para fornecer, manter e melhorar nossos serviços, processar transações, enviar avisos técnicos e mensagens de suporte, e responder a seus comentários e perguntas.", "We will retain your personal data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use your data to the extent necessary to comply with our legal obligations, resolve disputes, and enforce our policies.": "Reteremos seus dados pessoais apenas pelo tempo necessário para os fins estabelecidos nesta Política de Privacidade. Reteremos e usaremos seus dados na medida necessária para cumprir nossas obrigações legais, resolver disputas e fazer cumprir nossas políticas.", "Webhooks": "Webhooks", - "Welcome back": "Bem-vindo de volta", "When you create an account with us, you must provide accurate and complete information. You are responsible for safeguarding your account credentials and for all activity that occurs under your account.": "Ao criar uma conta conosco, você deve fornecer informações precisas e completas. Você é responsável por proteger suas credenciais de acesso e por toda a atividade que ocorrer em sua conta.", "Whoops, something went wrong on our servers.": "Ops, algo deu errado em nossos servidores.", + "Workspaces": "Workspaces", "You agree to use Saucebase only for lawful purposes and in accordance with these Terms. You must not use the service in any way that violates applicable local, national, or international laws or regulations.": "Você concorda em usar o Saucebase apenas para fins legais e de acordo com estes Termos. Você não deve usar o serviço de forma que viole leis ou regulamentos locais, nacionais ou internacionais aplicáveis.", - "You are now impersonating: :name": "Você está agora representando: :name", "Your foundation is ready to build something great!": "Sua fundação está pronta para construir algo grandioso!", "Your foundation is ready!": "Sua fundação está pronta!", - "Your magic login link for :app": "Seu link mágico de acesso para :app", "Your Module": "Seu Módulo", "Your recipe first. Modules for everything else": "Sua receita primeiro. Módulos para todo o resto", "Zapier": "Zapier" diff --git a/routes/navigation.php b/routes/navigation.php index 8acf43b7..057b24f6 100644 --- a/routes/navigation.php +++ b/routes/navigation.php @@ -1,8 +1,8 @@ name('index'); diff --git a/stubs/saucebase/recipes/basic/src/Filament/ModulePlugin.php b/stubs/saucebase/recipes/basic/src/Filament/ModulePlugin.php index 2c08c1b1..d993e1ee 100644 --- a/stubs/saucebase/recipes/basic/src/Filament/ModulePlugin.php +++ b/stubs/saucebase/recipes/basic/src/Filament/ModulePlugin.php @@ -2,9 +2,9 @@ namespace ___MODULE_NAMESPACE___\___Module___\Filament; -use App\Filament\ModulePlugin; use Filament\Contracts\Plugin; use Filament\Panel; +use Saucebase\Core\Filament\ModulePlugin; class ___Module___Plugin implements Plugin { diff --git a/stubs/saucebase/recipes/basic/src/Providers/ModuleServiceProvider.php b/stubs/saucebase/recipes/basic/src/Providers/ModuleServiceProvider.php index 9de5f6ab..0d88c466 100644 --- a/stubs/saucebase/recipes/basic/src/Providers/ModuleServiceProvider.php +++ b/stubs/saucebase/recipes/basic/src/Providers/ModuleServiceProvider.php @@ -2,7 +2,7 @@ namespace ___MODULE_NAMESPACE___\___Module___\Providers; -use App\Providers\ModuleServiceProvider; +use Saucebase\Core\Modules\ModuleServiceProvider; class ___Module___ServiceProvider extends ModuleServiceProvider { diff --git a/stubs/saucebase/stack/react/views/app.blade.php b/stubs/saucebase/stack/react/views/app.blade.php index 2b0a587c..7407bfd5 100644 --- a/stubs/saucebase/stack/react/views/app.blade.php +++ b/stubs/saucebase/stack/react/views/app.blade.php @@ -1,4 +1,4 @@ -@inject('generalSettings', App\Settings\GeneralSettings::class) +@inject('generalSettings', Saucebase\Core\Settings\GeneralSettings::class) ($appearance ?? 'system') == 'dark'])> diff --git a/stubs/saucebase/stack/vue/views/app.blade.php b/stubs/saucebase/stack/vue/views/app.blade.php index 47f578bc..5b5afb20 100644 --- a/stubs/saucebase/stack/vue/views/app.blade.php +++ b/stubs/saucebase/stack/vue/views/app.blade.php @@ -1,4 +1,4 @@ -@inject('generalSettings', App\Settings\GeneralSettings::class) +@inject('generalSettings', Saucebase\Core\Settings\GeneralSettings::class) ($appearance ?? 'system') == 'dark'])> diff --git a/tests/Feature/GeneralSettingsTest.php b/tests/Feature/GeneralSettingsTest.php index cbd537a1..07be501d 100644 --- a/tests/Feature/GeneralSettingsTest.php +++ b/tests/Feature/GeneralSettingsTest.php @@ -3,9 +3,7 @@ namespace Tests\Feature; use App\Enums\Role; -use App\Filament\Admin\Pages\GeneralSettings as GeneralSettingsPage; use App\Models\User; -use App\Settings\GeneralSettings; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Route; @@ -14,6 +12,8 @@ use Inertia\Testing\AssertableInertia; use Livewire\Livewire; use PHPUnit\Framework\Attributes\DataProvider; +use Saucebase\Core\Filament\Admin\GeneralSettings as GeneralSettingsPage; +use Saucebase\Core\Settings\GeneralSettings; use Tests\TestCase; class GeneralSettingsTest extends TestCase @@ -41,25 +41,6 @@ public function test_fresh_install_has_general_settings_defaults(): void $this->assertFalse($settings->prefer_logo); } - public function test_core_settings_migration_preserves_existing_values(): void - { - $settings = app(GeneralSettings::class); - $settings->site_name = 'Existing Platform'; - $settings->site_tagline = 'Existing tagline'; - $settings->site_description = 'Existing description.'; - $settings->save(); - - $migration = require database_path('settings/0001_01_01_000010_create_general_settings.php'); - $migration->up(); - - app()->forgetInstance(GeneralSettings::class); - $reloadedSettings = app(GeneralSettings::class); - - $this->assertSame('Existing Platform', $reloadedSettings->site_name); - $this->assertSame('Existing tagline', $reloadedSettings->site_tagline); - $this->assertSame('Existing description.', $reloadedSettings->site_description); - } - public function test_administrator_can_load_general_settings_form(): void { $admin = User::factory()->create(); diff --git a/tests/Feature/IndexControllerTest.php b/tests/Feature/IndexControllerTest.php index 12f4e40a..eb619050 100644 --- a/tests/Feature/IndexControllerTest.php +++ b/tests/Feature/IndexControllerTest.php @@ -2,9 +2,9 @@ namespace Tests\Feature; -use App\Services\FrontendConfig; use Illuminate\Foundation\Testing\RefreshDatabase; use InterNACHI\Modular\Support\ModuleRegistry; +use Saucebase\Core\Frontend\FrontendConfig; use Tests\TestCase; class IndexControllerTest extends TestCase diff --git a/tests/Feature/InertiaSSRTest.php b/tests/Feature/InertiaSSRTest.php index 5c631dcc..a13d8e06 100644 --- a/tests/Feature/InertiaSSRTest.php +++ b/tests/Feature/InertiaSSRTest.php @@ -2,12 +2,12 @@ namespace Tests\Feature; -use App\Services\FrontendConfig; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Inertia\Response; +use Saucebase\Core\Frontend\FrontendConfig; use Tests\TestCase; class InertiaSSRTest extends TestCase diff --git a/tests/Feature/LocalizationSettingsTest.php b/tests/Feature/LocalizationSettingsTest.php index a280de05..0d9b5c79 100644 --- a/tests/Feature/LocalizationSettingsTest.php +++ b/tests/Feature/LocalizationSettingsTest.php @@ -3,14 +3,14 @@ namespace Tests\Feature; use App\Enums\Role; -use App\Filament\Admin\Pages\LocalizationSettings as LocalizationSettingsPage; use App\Models\User; -use App\Settings\LocalizationSettings; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Inertia\Testing\AssertableInertia; use Livewire\Livewire; +use Saucebase\Core\Filament\Admin\LocalizationSettings as LocalizationSettingsPage; +use Saucebase\Core\Localization\LocalizationSettings; use Tests\TestCase; class LocalizationSettingsTest extends TestCase @@ -32,19 +32,6 @@ public function test_fresh_install_offers_the_locales_that_ship(): void $this->assertSame('en', $settings->default_locale); } - public function test_settings_migration_preserves_existing_values(): void - { - $this->setEnabledLocales(['en'], 'en'); - - $migration = require database_path('settings/0001_01_01_000012_create_localization_settings.php'); - $migration->up(); - - app()->forgetInstance(LocalizationSettings::class); - $settings = app(LocalizationSettings::class); - - $this->assertSame(['en'], $settings->enabled_locales); - } - public function test_available_locales_are_discovered_from_lang_directories(): void { $available = app(LocalizationSettings::class)->available(); diff --git a/tests/Feature/NotificationTranslatabilityTest.php b/tests/Feature/NotificationTranslatabilityTest.php deleted file mode 100644 index b9902dd9..00000000 --- a/tests/Feature/NotificationTranslatabilityTest.php +++ /dev/null @@ -1,113 +0,0 @@ -create(['name' => 'Ana']); - - App::setLocale('xx'); - - $mail = (new PasswordChangedNotification)->toMail($user); - - // Merged, because MailMessage sorts lines into intro or outro depending on whether - // an action came before them — and the action here is absent without the settings - // module. Which bucket a line lands in is not what this test is about. - $lines = [...$mail->introLines, ...$mail->outroLines]; - - $this->assertSame('ALTERADA', $mail->subject); - $this->assertContains('TROCADA', $lines); - $this->assertContains('CONTATE', $lines); - $this->assertContains('OBRIGADO', $lines); - } - - public function test_the_recipient_name_is_a_placeholder_not_a_concatenation(): void - { - $user = User::factory()->create(['name' => 'Ana']); - - App::setLocale('xx'); - - // Building the greeting with "." would leave "Hello" permanently English. - $this->assertSame('OLA Ana,', (new PasswordChangedNotification)->toMail($user)->greeting); - } - - /** - * The profile button points into the settings module, which core installs without. - */ - public function test_the_profile_button_appears_only_where_the_route_exists(): void - { - $user = User::factory()->create(); - - App::setLocale('xx'); - - if (Route::has('settings.profile')) { - $this->assertSame('PERFIL', (new PasswordChangedNotification)->toMail($user)->actionText); - - return; - } - - // Without the settings module the mail still sends, minus the button. - $this->assertNull((new PasswordChangedNotification)->toMail($user)->actionText); - } - - /** - * `format()` hardcodes English month and meridiem names. `isoFormat()` only helps if - * Carbon has been told which language the application is speaking. - */ - public function test_dates_in_mail_follow_the_application_language(): void - { - $user = User::factory()->create(); - - Carbon::setTestNow('2026-08-24 15:19:00'); - App::setLocale('pt_BR'); - - $mail = (new PasswordChangedNotification)->toMail($user); - $line = collect($mail->introLines)->first(fn (string $line): bool => str_contains($line, '2026')); - - $this->assertNotNull($line, 'Expected a line carrying the change time.'); - $this->assertStringContainsString('agosto', $line); - } - - public function test_carbon_tracks_the_application_locale_in_both_directions(): void - { - App::setLocale('pt_BR'); - $this->assertSame('pt_BR', Carbon::getLocale()); - - App::setLocale('en'); - $this->assertSame('en', Carbon::getLocale()); - } -} diff --git a/tests/Feature/SettingsInfrastructureTest.php b/tests/Feature/SettingsInfrastructureTest.php index b63cb9ea..fcbacf24 100644 --- a/tests/Feature/SettingsInfrastructureTest.php +++ b/tests/Feature/SettingsInfrastructureTest.php @@ -2,34 +2,38 @@ namespace Tests\Feature; -use App\Filament\Pages\SettingsPage; use Filament\Pages\SettingsPage as FilamentSettingsPage; use Filament\SpatieLaravelSettingsPluginServiceProvider; use Filament\Support\Enums\Width; use Illuminate\Support\Facades\Schema; use ReflectionClass; +use Saucebase\Core\Filament\SettingsPage; use Tests\TestCase; class SettingsInfrastructureTest extends TestCase { /** - * Modules ship settings pages without requiring the plugin themselves, so root has to - * carry it. Presence is asserted, not the constraint: which version satisfies this is + * Modules ship settings pages without requiring the plugin themselves, so something + * beneath them has to carry it. That used to be this application; since the v3 core + * extraction it is saucebase/core, which owns the SettingsPage base class the pages + * extend — the dependency now sits with the code that needs it. + * + * Presence is asserted, not the constraint: which version satisfies this is * `composer.json`'s business, and pinning it here only breaks the test on a bump it * has nothing to say about. */ - public function test_root_application_provides_settings_infrastructure_to_modules(): void + public function test_core_provides_settings_infrastructure_to_modules(): void { - $rootComposer = json_decode( - file_get_contents(base_path('composer.json')), + $coreComposer = json_decode( + file_get_contents($this->corePackagePath('composer.json')), true, flags: JSON_THROW_ON_ERROR, ); $this->assertArrayHasKey( 'filament/spatie-laravel-settings-plugin', - $rootComposer['require'], - 'The settings plugin must be required by the root application, not by a module.', + $coreComposer['require'], + 'The settings plugin must be required by saucebase/core, not by a module.', ); $this->assertTrue( @@ -38,6 +42,19 @@ class_exists(SpatieLaravelSettingsPluginServiceProvider::class), ); } + /** + * Resolve a path inside the installed saucebase/core package. + * + * Uses the class map rather than a hardcoded vendor path, so this works whether core + * is symlinked from packages/ during development or installed from Packagist. + */ + private function corePackagePath(string $path): string + { + $src = dirname((new ReflectionClass(SettingsPage::class))->getFileName()); + + return dirname($src, 2).'/'.$path; + } + public function test_root_application_migrations_create_the_settings_repository(): void { $this->artisan('migrate:fresh', [ diff --git a/tests/Feature/SettingsModalTest.php b/tests/Feature/SettingsModalTest.php index ae00b1f9..af4ba7b6 100644 --- a/tests/Feature/SettingsModalTest.php +++ b/tests/Feature/SettingsModalTest.php @@ -2,12 +2,12 @@ namespace Tests\Feature; -use App\Settings\SectionRegistry; -use App\Settings\SettingsSection; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Route; use Inertia\Inertia; +use Saucebase\Core\Settings\SectionRegistry; +use Saucebase\Core\Settings\SettingsSection; use Tests\TestCase; class SettingsModalTest extends TestCase diff --git a/tests/Feature/SettingsNavigationTest.php b/tests/Feature/SettingsNavigationTest.php index 346aea56..00871b46 100644 --- a/tests/Feature/SettingsNavigationTest.php +++ b/tests/Feature/SettingsNavigationTest.php @@ -3,11 +3,11 @@ namespace Tests\Feature; use App\Enums\Role; -use App\Filament\Admin\Pages\GeneralSettings; use App\Models\User; use Filament\Facades\Filament; use Filament\Navigation\NavigationGroup; use Illuminate\Foundation\Testing\RefreshDatabase; +use Saucebase\Core\Filament\Admin\GeneralSettings; use Tests\TestCase; class SettingsNavigationTest extends TestCase diff --git a/tests/TestCase.php b/tests/TestCase.php index 46961e34..b1f8e159 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,18 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\Vite; +/** + * Shared test setup for the application. + * + * A base test case in saucebase/core would let modules inherit this instead of + * duplicating it, but the helpers worth sharing (createUser, AuthHelper, + * TestFixtures) all exist to manufacture users with roles — so sharing them means + * first giving core a way to talk about users and roles without naming + * App\Models\User and App\Enums\Role. That is sc-690's job, not this file's. + * + * @see https://app.shortcut.com/saucebase/story/690 Extensible RBAC + * @see https://app.shortcut.com/saucebase/story/701 Move core's tests into the package + */ abstract class TestCase extends BaseTestCase { protected $seed = true; diff --git a/tests/Unit/Console/Commands/RecipeToModuleCommandTest.php b/tests/Unit/Console/Commands/RecipeToModuleCommandTest.php index 8a20e749..0640872e 100644 --- a/tests/Unit/Console/Commands/RecipeToModuleCommandTest.php +++ b/tests/Unit/Console/Commands/RecipeToModuleCommandTest.php @@ -2,10 +2,10 @@ namespace Tests\Unit\Console\Commands; -use App\Console\Commands\SauceBase\RecipeToModuleCommand; use Illuminate\Console\OutputStyle; use Illuminate\Filesystem\Filesystem; use PHPUnit\Framework\Attributes\DataProvider; +use Saucebase\Core\Modules\RecipeToModuleCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; use Tests\TestCase; diff --git a/tests/Unit/Services/NavigationTest.php b/tests/Unit/Services/NavigationTest.php deleted file mode 100644 index 04c6c233..00000000 --- a/tests/Unit/Services/NavigationTest.php +++ /dev/null @@ -1,604 +0,0 @@ -navigation = new Navigation(app(ActiveUrlChecker::class)); - } - - public function test_add_registers_navigation_item(): void - { - $this->navigation->add('Dashboard', '/dashboard', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $tree = $this->navigation->tree(); - - $this->assertCount(1, $tree); - $this->assertEquals('Dashboard', $tree[0]['title']); - $this->assertStringContainsString('/dashboard', $tree[0]['url']); - } - - public function test_add_when_includes_item_when_condition_is_true(): void - { - $this->navigation->addWhen( - fn () => true, - 'Visible', - '/visible', - function (Section $section) { - $section->attributes(['group' => 'main']); - } - ); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertArrayHasKey('main', $grouped); - $this->assertCount(1, $grouped['main']); - $this->assertEquals('Visible', $grouped['main'][0]['title']); - } - - public function test_add_when_excludes_item_when_condition_is_false(): void - { - $this->navigation->addWhen( - fn () => false, - 'Hidden', - '/hidden', - function (Section $section) { - $section->attributes(['group' => 'main']); - } - ); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertEmpty($grouped['main'] ?? []); - } - - public function test_tree_grouped_groups_items_by_group_attribute(): void - { - $this->navigation->add('Dashboard', '/dashboard', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $this->navigation->add('Settings', '/settings', function (Section $section) { - $section->attributes(['group' => 'settings']); - }); - - $this->navigation->add('Docs', '/docs', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertArrayHasKey('main', $grouped); - $this->assertArrayHasKey('settings', $grouped); - $this->assertCount(2, $grouped['main']); - $this->assertCount(1, $grouped['settings']); - } - - public function test_tree_grouped_uses_ungrouped_for_items_without_group(): void - { - $this->navigation->add('Orphan', '/orphan'); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertArrayHasKey('ungrouped', $grouped); - $this->assertCount(1, $grouped['ungrouped']); - } - - public function test_items_are_sorted_by_order_attribute(): void - { - $this->navigation->add('Third', '/third', function (Section $section) { - $section->attributes(['group' => 'main', 'order' => 30]); - }); - - $this->navigation->add('First', '/first', function (Section $section) { - $section->attributes(['group' => 'main', 'order' => 10]); - }); - - $this->navigation->add('Second', '/second', function (Section $section) { - $section->attributes(['group' => 'main', 'order' => 20]); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertEquals('First', $grouped['main'][0]['title']); - $this->assertEquals('Second', $grouped['main'][1]['title']); - $this->assertEquals('Third', $grouped['main'][2]['title']); - } - - public function test_transform_item_produces_correct_structure(): void - { - $this->navigation->add('Dashboard', '/dashboard', function (Section $section) { - $section->attributes([ - 'group' => 'main', - 'slug' => 'dashboard', - 'order' => 0, - ]); - }); - - $grouped = $this->navigation->treeGrouped(); - $item = $grouped['main'][0]; - - $this->assertArrayHasKey('title', $item); - $this->assertArrayHasKey('active', $item); - $this->assertArrayHasKey('slug', $item); - $this->assertArrayHasKey('url', $item); - $this->assertEquals('Dashboard', $item['title']); - $this->assertEquals('dashboard', $item['slug']); - $this->assertFalse($item['active']); - } - - public function test_transform_item_includes_icon_attribute(): void - { - $this->navigation->add('Roadmap', '/roadmap', function (Section $section) { - $section->attributes([ - 'group' => 'main', - 'slug' => 'roadmap-slug', - 'icon' => 'roadmap', - ]); - }); - - $grouped = $this->navigation->treeGrouped(); - $item = $grouped['main'][0]; - - $this->assertEquals('roadmap-slug', $item['slug']); - $this->assertEquals('roadmap', $item['icon']); - } - - public function test_transform_item_includes_optional_attributes(): void - { - $this->navigation->add('Admin', '/admin', function (Section $section) { - $section->attributes([ - 'group' => 'main', - 'external' => true, - 'newPage' => true, - 'class' => 'text-red-500', - 'badge' => ['content' => 'New', 'variant' => 'info'], - ]); - }); - - $grouped = $this->navigation->treeGrouped(); - $item = $grouped['main'][0]; - - $this->assertTrue($item['external']); - $this->assertTrue($item['newPage']); - $this->assertEquals('text-red-500', $item['class']); - $this->assertEquals(['content' => 'New', 'variant' => 'info'], $item['badge']); - } - - public function test_transform_item_excludes_internal_attributes(): void - { - $this->navigation->addWhen( - fn () => true, - 'Item', - '/item', - function (Section $section) { - $section->attributes([ - 'group' => 'main', - 'order' => 5, - ]); - } - ); - - $grouped = $this->navigation->treeGrouped(); - $item = $grouped['main'][0]; - - $this->assertArrayNotHasKey('when', $item); - $this->assertArrayNotHasKey('group', $item); - $this->assertArrayNotHasKey('order', $item); - } - - public function test_transform_item_generates_slug_from_title(): void - { - $this->navigation->add('Star us on Github', '/github', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertEquals('star-us-on-github', $grouped['main'][0]['slug']); - } - - public function test_is_item_active_returns_false_when_no_url(): void - { - $this->navigation->add('No URL', '', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertFalse($grouped['main'][0]['active']); - } - - public function test_children_are_transformed_recursively(): void - { - $this->navigation->add('Parent', '/parent', function (Section $section) { - $section->attributes(['group' => 'main', 'order' => 0]); - - $section->add('Child', '/child', function (Section $child) { - $child->attributes(['order' => 0]); - }); - }); - - $grouped = $this->navigation->treeGrouped(); - $parent = $grouped['main'][0]; - - $this->assertArrayHasKey('children', $parent); - $this->assertCount(1, $parent['children']); - $this->assertEquals('Child', $parent['children'][0]['title']); - $this->assertArrayHasKey('slug', $parent['children'][0]); - } - - public function test_facade_resolves_to_custom_navigation(): void - { - $resolved = NavigationFacade::getFacadeRoot(); - - $this->assertInstanceOf(Navigation::class, $resolved); - } - - public function test_filter_callable_removes_children_with_false_condition(): void - { - $this->navigation->add('Parent', '/parent', function (Section $section) { - $section->attributes(['group' => 'main']); - - $section->add('Visible Child', '/visible', function (Section $child) { - $child->attributes(['when' => fn () => true]); - }); - - $section->add('Hidden Child', '/hidden', function (Section $child) { - $child->attributes(['when' => fn () => false]); - }); - }); - - $grouped = $this->navigation->treeGrouped(); - $parent = $grouped['main'][0]; - - $this->assertCount(1, $parent['children']); - $this->assertEquals('Visible Child', $parent['children'][0]['title']); - } - - // --- isItemActive (URL matching) --- - - public function test_is_item_active_returns_true_for_exact_url_match(): void - { - app()->instance('request', Request::create('http://localhost/dashboard')); - - $this->navigation->add('Dashboard', 'http://localhost/dashboard', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertTrue($grouped['main'][0]['active']); - } - - public function test_is_item_active_returns_false_for_different_url(): void - { - app()->instance('request', Request::create('http://localhost/settings')); - - $this->navigation->add('Dashboard', 'http://localhost/dashboard', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertFalse($grouped['main'][0]['active']); - } - - public function test_is_item_active_normalizes_trailing_slashes(): void - { - app()->instance('request', Request::create('http://localhost/dashboard/')); - - $this->navigation->add('Dashboard', 'http://localhost/dashboard', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertTrue($grouped['main'][0]['active']); - } - - // --- addWhen edge cases --- - - public function test_add_when_without_configure_callback(): void - { - $this->navigation->addWhen(fn () => true, 'Item', '/item'); - - $tree = $this->navigation->tree(); - - $this->assertCount(1, $tree); - $this->assertEquals('Item', $tree[0]['title']); - } - - public function test_add_when_is_fluent(): void - { - $result = $this->navigation->addWhen(fn () => true, 'Item', '/item'); - - $this->assertSame($this->navigation, $result); - } - - public function test_add_when_mixed_conditions_in_same_group(): void - { - $this->navigation->addWhen(fn () => true, 'Visible', '/visible', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $this->navigation->addWhen(fn () => false, 'Hidden', '/hidden', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $this->navigation->addWhen(fn () => true, 'Also Visible', '/also-visible', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertCount(2, $grouped['main']); - $this->assertEquals('Visible', $grouped['main'][0]['title']); - $this->assertEquals('Also Visible', $grouped['main'][1]['title']); - } - - // --- load() method --- - - public function test_load_returns_self(): void - { - $result = $this->navigation->load(); - - $this->assertSame($this->navigation, $result); - } - - public function test_load_loads_core_navigation_file(): void - { - // Swap the container binding to our fresh test instance and use include - // (not require_once) to reliably re-execute the navigation file - NavigationFacade::clearResolvedInstances(); - app()->instance(Navigation::class, $this->navigation); - - include base_path('routes/navigation.php'); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertArrayHasKey('main', $grouped); - } - - public function test_load_discovers_modules_via_registry(): void - { - // With InterNACHI all present modules are active — no status file needed. - // Verify that load() completes without errors and returns a valid structure. - $navigation = new Navigation(app(ActiveUrlChecker::class)); - $navigation->load(); - - $grouped = $navigation->treeGrouped(); - - // treeGrouped() always returns an array (may be empty if no navigation registered) - $this->assertIsArray($grouped); - } - - // --- Service provider integration --- - - public function test_container_resolves_custom_navigation_class(): void - { - $resolved = app(Navigation::class); - - $this->assertInstanceOf(Navigation::class, $resolved); - } - - public function test_spatie_alias_resolves_to_custom_class(): void - { - $resolved = app(\Spatie\Navigation\Navigation::class); - - $this->assertInstanceOf(Navigation::class, $resolved); - } - - public function test_scoped_binding_returns_same_instance_within_request(): void - { - $first = app(Navigation::class); - $second = app(Navigation::class); - - $this->assertSame($first, $second); - } - - // --- Edge cases --- - - public function test_empty_navigation_returns_empty_grouped(): void - { - $grouped = $this->navigation->treeGrouped(); - - $this->assertEmpty($grouped); - } - - public function test_items_without_order_default_to_last(): void - { - $this->navigation->add('No Order', '/no-order', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $this->navigation->add('Ordered', '/ordered', function (Section $section) { - $section->attributes(['group' => 'main', 'order' => 10]); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertEquals('Ordered', $grouped['main'][0]['title']); - $this->assertEquals('No Order', $grouped['main'][1]['title']); - } - - public function test_action_item_with_hash_url(): void - { - app()->instance('request', Request::create('http://localhost/dashboard')); - - $this->navigation->add('Log out', '#', function (Section $section) { - $section->attributes([ - 'group' => 'user', - 'action' => 'logout', - 'slug' => 'logout', - ]); - }); - - $grouped = $this->navigation->treeGrouped(); - $item = $grouped['user'][0]; - - $this->assertEquals('Log out', $item['title']); - $this->assertEquals('logout', $item['action']); - $this->assertEquals('logout', $item['slug']); - $this->assertFalse($item['active']); - } - - public function test_children_sorted_by_order(): void - { - $this->navigation->add('Parent', '/parent', function (Section $section) { - $section->attributes(['group' => 'main', 'order' => 0]); - - $section->add('Third', '/third', function (Section $child) { - $child->attributes(['order' => 30]); - }); - - $section->add('First', '/first', function (Section $child) { - $child->attributes(['order' => 10]); - }); - - $section->add('Second', '/second', function (Section $child) { - $child->attributes(['order' => 20]); - }); - }); - - $grouped = $this->navigation->treeGrouped(); - $children = $grouped['main'][0]['children']; - - $this->assertEquals('First', $children[0]['title']); - $this->assertEquals('Second', $children[1]['title']); - $this->assertEquals('Third', $children[2]['title']); - } - - // --- Lazy (Closure) URL resolution --- - - public function test_add_accepts_closure_url_and_resolves_at_render_time(): void - { - $this->navigation->add('Invoices', fn () => '/invoices', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertArrayHasKey('url', $grouped['main'][0]); - $this->assertEquals('/invoices', $grouped['main'][0]['url']); - } - - public function test_closure_url_is_not_invoked_at_registration_time(): void - { - $invocationCount = 0; - - $this->navigation->add('Invoices', function () use (&$invocationCount) { - $invocationCount++; - - return '/invoices'; - }, function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $this->assertEquals(0, $invocationCount, 'Closure should not be called during add()'); - - $this->navigation->treeGrouped(); - - $this->assertEquals(1, $invocationCount, 'Closure should be called exactly once at render time'); - } - - public function test_url_resolver_does_not_appear_in_menu_item_output(): void - { - $this->navigation->add('Invoices', fn () => '/invoices', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - $item = $grouped['main'][0]; - - $this->assertArrayNotHasKey('url_resolver', $item); - } - - public function test_is_item_active_works_with_closure_url(): void - { - app()->instance('request', Request::create('http://localhost/invoices')); - - $this->navigation->add('Invoices', fn () => 'http://localhost/invoices', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertTrue($grouped['main'][0]['active']); - } - - public function test_is_item_active_not_active_with_closure_url_mismatch(): void - { - app()->instance('request', Request::create('http://localhost/dashboard')); - - $this->navigation->add('Invoices', fn () => 'http://localhost/invoices', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertFalse($grouped['main'][0]['active']); - } - - public function test_add_when_accepts_closure_url(): void - { - $this->navigation->addWhen( - fn () => true, - 'Invoices', - fn () => '/invoices', - function (Section $section) { - $section->attributes(['group' => 'main']); - } - ); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertArrayHasKey('main', $grouped); - $this->assertEquals('/invoices', $grouped['main'][0]['url']); - } - - public function test_child_section_add_accepts_closure_url(): void - { - $this->navigation->add('Parent', '/parent', function (Section $section) { - $section->attributes(['group' => 'main']); - - $section->add('Child', fn () => '/child-lazy'); - }); - - $grouped = $this->navigation->treeGrouped(); - $child = $grouped['main'][0]['children'][0]; - - $this->assertEquals('Child', $child['title']); - $this->assertEquals('/child-lazy', $child['url']); - } - - public function test_add_with_static_string_url_still_works_after_refactor(): void - { - $this->navigation->add('Dashboard', '/static', function (Section $section) { - $section->attributes(['group' => 'main']); - }); - - $grouped = $this->navigation->treeGrouped(); - - $this->assertEquals('/static', $grouped['main'][0]['url']); - } -} From 6d7856c560b14c2ed084bcf8195779689449c265 Mon Sep 17 00:00:00 2001 From: roble Date: Thu, 10 Sep 2026 11:21:49 +0100 Subject: [PATCH 3/8] Refactor test suite: remove obsolete tests and add route access validation - Deleted IndexControllerTest, InertiaSSRTest, LocalizationSettingsTest, SettingsInfrastructureTest, SettingsModalTest, SettingsNavigationTest, and RecipeToModuleCommandTest due to redundancy or changes in application structure. - Introduced RouteAccessTest to validate authentication requirements for application routes. --- tests/Feature/AdminNavigationOrderTest.php | 69 ----- tests/Feature/GeneralSettingsTest.php | 280 ------------------ tests/Feature/IndexControllerTest.php | 66 ----- tests/Feature/InertiaSSRTest.php | 152 ---------- tests/Feature/LocalizationSettingsTest.php | 253 ---------------- tests/Feature/RouteAccessTest.php | 71 +++++ tests/Feature/SettingsInfrastructureTest.php | 138 --------- tests/Feature/SettingsModalTest.php | 236 --------------- tests/Feature/SettingsNavigationTest.php | 58 ---- .../Commands/RecipeToModuleCommandTest.php | 270 ----------------- 10 files changed, 71 insertions(+), 1522 deletions(-) delete mode 100644 tests/Feature/AdminNavigationOrderTest.php delete mode 100644 tests/Feature/GeneralSettingsTest.php delete mode 100644 tests/Feature/IndexControllerTest.php delete mode 100644 tests/Feature/InertiaSSRTest.php delete mode 100644 tests/Feature/LocalizationSettingsTest.php create mode 100644 tests/Feature/RouteAccessTest.php delete mode 100644 tests/Feature/SettingsInfrastructureTest.php delete mode 100644 tests/Feature/SettingsModalTest.php delete mode 100644 tests/Feature/SettingsNavigationTest.php delete mode 100644 tests/Unit/Console/Commands/RecipeToModuleCommandTest.php diff --git a/tests/Feature/AdminNavigationOrderTest.php b/tests/Feature/AdminNavigationOrderTest.php deleted file mode 100644 index 324028db..00000000 --- a/tests/Feature/AdminNavigationOrderTest.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ - private function navigationLabels(): array - { - $user = User::factory()->create(); - $user->assignRole(Role::ADMIN); - - $this->actingAs($user); - - Filament::setCurrentPanel('admin'); - Filament::bootCurrentPanel(); - - $labels = []; - - foreach (Filament::getNavigation() as $group) { - $label = $group instanceof NavigationGroup ? $group->getLabel() : null; - - foreach ($group->getItems() as $item) { - $labels[] = trim(($label ?? '').' > '.$item->getLabel(), ' >'); - } - } - - return $labels; - } - - public function test_settings_is_the_last_thing_in_the_sidebar(): void - { - $labels = $this->navigationLabels(); - - $this->assertNotEmpty($labels); - - $settings = array_values(array_filter( - $labels, - fn (string $label): bool => str_contains($label, 'Settings'), - )); - - $this->assertNotEmpty($settings, 'No settings entry found in: '.implode(' | ', $labels)); - - $this->assertSame( - end($settings), - end($labels), - 'Settings should be last. Order was: '.implode(' | ', $labels), - ); - } -} diff --git a/tests/Feature/GeneralSettingsTest.php b/tests/Feature/GeneralSettingsTest.php deleted file mode 100644 index 07be501d..00000000 --- a/tests/Feature/GeneralSettingsTest.php +++ /dev/null @@ -1,280 +0,0 @@ -get('/general-settings-probe', fn () => Inertia::render('Index')); - } - - public function test_fresh_install_has_general_settings_defaults(): void - { - $settings = app(GeneralSettings::class); - - $this->assertSame('Saucebase', $settings->site_name); - $this->assertNull($settings->site_tagline); - $this->assertNull($settings->site_description); - - // No brand images, so the logo and the favicon both stay the ones that ship. - $this->assertNull($settings->site_icon); - $this->assertNull($settings->site_logo); - $this->assertFalse($settings->prefer_logo); - } - - public function test_administrator_can_load_general_settings_form(): void - { - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - Livewire::test(GeneralSettingsPage::class) - ->assertSchemaStateSet([ - 'site_name' => 'Saucebase', - 'site_tagline' => null, - 'site_description' => null, - 'site_icon' => null, - 'site_logo' => null, - 'prefer_logo' => false, - ]); - } - - public function test_administrator_can_save_general_settings(): void - { - Storage::fake('public'); - - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - Livewire::test(GeneralSettingsPage::class) - ->fillForm([ - 'site_name' => 'Acme Platform', - 'site_tagline' => 'The modular SaaS starter kit', - 'site_description' => 'The Acme customer platform.', - 'site_icon' => UploadedFile::fake()->image('icon.png', 512, 512), - 'site_logo' => UploadedFile::fake()->image('logo.png', 1200, 400), - 'prefer_logo' => true, - ]) - ->call('save') - ->assertHasNoFormErrors() - ->assertNotified(); - - app()->forgetInstance(GeneralSettings::class); - $settings = app(GeneralSettings::class); - - $this->assertSame('Acme Platform', $settings->site_name); - $this->assertSame('The modular SaaS starter kit', $settings->site_tagline); - $this->assertSame('The Acme customer platform.', $settings->site_description); - $this->assertStringStartsWith('site-branding/', $settings->site_icon); - $this->assertStringStartsWith('site-branding/', $settings->site_logo); - $this->assertTrue($settings->prefer_logo); - Storage::disk('public')->assertExists($settings->site_icon); - Storage::disk('public')->assertExists($settings->site_logo); - $this->assertSame(Storage::disk('public')->url($settings->site_icon), $settings->siteIconUrl()); - $this->assertSame(Storage::disk('public')->url($settings->site_logo), $settings->siteLogoUrl()); - } - - public function test_site_name_is_required_without_changing_persisted_settings(): void - { - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - Livewire::test(GeneralSettingsPage::class) - ->fillForm([ - 'site_name' => null, - 'site_description' => 'Changed description', - ]) - ->call('save') - ->assertHasFormErrors(['site_name' => 'required']) - ->assertNotNotified(); - - $settings = app(GeneralSettings::class); - - $this->assertSame('Saucebase', $settings->site_name); - $this->assertNull($settings->site_description); - } - - #[DataProvider('brandImageFieldsProvider')] - public function test_branding_uploads_must_be_images(string $field): void - { - Storage::fake('public'); - - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - Livewire::test(GeneralSettingsPage::class) - ->fillForm([ - 'site_name' => 'Saucebase', - $field => UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'), - ]) - ->call('save') - ->assertHasFormErrors([$field]) - ->assertNotNotified(); - } - - #[DataProvider('brandImageFieldsProvider')] - public function test_branding_uploads_must_not_exceed_one_megabyte(string $field): void - { - Storage::fake('public'); - - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - Livewire::test(GeneralSettingsPage::class) - ->fillForm([ - 'site_name' => 'Saucebase', - $field => UploadedFile::fake()->image('large.png')->size(1025), - ]) - ->call('save') - ->assertHasFormErrors([$field]) - ->assertNotNotified(); - } - - #[DataProvider('invalidSettingsProvider')] - public function test_invalid_settings_do_not_change_persisted_values( - string $field, - string $value, - string $rule, - ): void { - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - Livewire::test(GeneralSettingsPage::class) - ->fillForm([ - 'site_name' => 'Saucebase', - 'site_description' => null, - $field => $value, - ]) - ->call('save') - ->assertHasFormErrors([$field => $rule]); - - $settings = app(GeneralSettings::class); - - $this->assertSame('Saucebase', $settings->site_name); - $this->assertNull($settings->site_description); - } - - public function test_regular_user_cannot_access_general_settings_page(): void - { - $user = User::factory()->create(); - $user->assignRole(Role::USER); - - $this->actingAs($user) - ->get(GeneralSettingsPage::getUrl(panel: 'admin')) - ->assertForbidden(); - } - - public function test_general_settings_are_shared_with_inertia(): void - { - Storage::fake('public'); - Storage::disk('public')->put('site-branding/icon.png', 'icon'); - - $settings = app(GeneralSettings::class); - $settings->site_name = 'Acme Platform'; - $settings->site_tagline = 'The modular SaaS starter kit'; - $settings->site_description = 'The Acme customer platform.'; - $settings->site_icon = 'site-branding/icon.png'; - $settings->site_logo = 'https://cdn.example.com/logo.svg'; - $settings->save(); - - $this->get('/general-settings-probe') - ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page - ->where('settings.general.site_name', 'Acme Platform') - ->where('settings.general.site_tagline', 'The modular SaaS starter kit') - ->where('settings.general.site_description', 'The Acme customer platform.') - ->where('settings.general.site_icon', Storage::disk('public')->url('site-branding/icon.png')) - ->where('settings.general.site_logo', 'https://cdn.example.com/logo.svg')); - } - - public function test_root_relative_branding_urls_are_not_resolved_as_storage_paths(): void - { - $settings = app(GeneralSettings::class); - $settings->site_icon = '/storage/tenant-logos/icon.png'; - $settings->site_logo = '/storage/tenant-logos/logo.png'; - - $this->assertSame('/storage/tenant-logos/icon.png', $settings->siteIconUrl()); - $this->assertSame('/storage/tenant-logos/logo.png', $settings->siteLogoUrl()); - } - - public function test_frontend_stack_views_render_settings_driven_metadata(): void - { - foreach (['vue', 'react'] as $stack) { - $template = file_get_contents(base_path("stubs/saucebase/stack/{$stack}/views/app.blade.php")); - - $this->assertIsString($template); - $this->assertStringContainsString( - '{{ $generalSettings->site_name }}', - $template, - ); - $this->assertMatchesRegularExpression( - '/@if \(\$generalSettings->site_description\).*name="description".*@endif/s', - $template, - ); - } - } - - /** - * @return array - */ - public static function invalidSettingsProvider(): array - { - return [ - 'site name too long' => [ - 'field' => 'site_name', - 'value' => str_repeat('a', 256), - 'rule' => 'max', - ], - 'site tagline too long' => [ - 'field' => 'site_tagline', - 'value' => str_repeat('a', 61), - 'rule' => 'max', - ], - 'site description too long' => [ - 'field' => 'site_description', - 'value' => str_repeat('a', 501), - 'rule' => 'max', - ], - ]; - } - - /** - * @return array - */ - public static function brandImageFieldsProvider(): array - { - return [ - 'site icon' => ['field' => 'site_icon'], - 'site logo' => ['field' => 'site_logo'], - ]; - } -} diff --git a/tests/Feature/IndexControllerTest.php b/tests/Feature/IndexControllerTest.php deleted file mode 100644 index eb619050..00000000 --- a/tests/Feature/IndexControllerTest.php +++ /dev/null @@ -1,66 +0,0 @@ -module('demo') !== null) { - $this->markTestSkipped('IndexController is not the / route handler when the Demo module is active.'); - } - } - - private function bindFramework(?string $framework): void - { - app()->bind(FrontendConfig::class, fn () => new class($framework) extends FrontendConfig - { - public function __construct(private readonly ?string $fw) {} - - public function getFramework(): ?string - { - return $this->fw; - } - }); - } - - public function test_renders_setup_blade_when_framework_is_null(): void - { - $this->bindFramework(null); - - $this->get('/')->assertStatus(200)->assertViewIs('setup'); - } - - public function test_renders_setup_blade_when_frontend_json_missing(): void - { - $this->bindFramework(null); - - $this->get('/')->assertStatus(200)->assertViewIs('setup'); - } - - public function test_renders_inertia_when_framework_is_set(): void - { - $this->bindFramework('vue'); - - $this->get('/')->assertStatus(200)->assertViewIs('app'); - } - - public function test_setup_page_shows_vue_and_react_commands(): void - { - $this->bindFramework(null); - - $this->get('/') - ->assertSeeText('saucebase stack vue') - ->assertSeeText('saucebase stack react') - ->assertDontSeeText('php artisan saucebase:install'); - } -} diff --git a/tests/Feature/InertiaSSRTest.php b/tests/Feature/InertiaSSRTest.php deleted file mode 100644 index a13d8e06..00000000 --- a/tests/Feature/InertiaSSRTest.php +++ /dev/null @@ -1,152 +0,0 @@ -assertTrue(config('inertia.ssr.enabled')); - } - - public function test_with_ssr_macro_enables_ssr(): void - { - // Create an Inertia response and call withSSR() - $response = Inertia::render('Index')->withSSR(); - - // Verify SSR was enabled - $this->assertTrue(config('inertia.ssr.enabled')); - - // Verify it returns the response (chainable) - $this->assertInstanceOf(Response::class, $response); - } - - public function test_inertia_response_without_with_ssr_keeps_ssr_disabled(): void - { - // Simulate middleware having disabled SSR - Config::set('inertia.ssr.enabled', false); - - // Create an Inertia response without calling withSSR() - Inertia::render('Dashboard'); - - // Verify SSR is still disabled - $this->assertFalse(config('inertia.ssr.enabled')); - } - - public function test_with_ssr_is_chainable_with_other_methods(): void - { - $response = Inertia::render('Index') - ->with('foo', 'bar') - ->withSSR() - ->with('baz', 'qux'); - - // Verify SSR was enabled - $this->assertTrue(config('inertia.ssr.enabled')); - - // Verify it's still a proper Inertia response - $this->assertInstanceOf(Response::class, $response); - } - - public function test_home_page_uses_ssr(): void - { - $this->assertTrue(config('inertia.ssr.enabled'), 'SSR should be enabled in config at boot'); - - app()->bind(FrontendConfig::class, fn () => new class extends FrontendConfig - { - public function getFramework(): ?string - { - return 'vue'; - } - }); - - $this->get('/')->assertOk(); - - $this->assertTrue(config('inertia.ssr.enabled'), 'SSR should be enabled after ->withSSR()'); - } - - public function test_without_ssr_macro_disables_ssr(): void - { - // Start with SSR enabled - Config::set('inertia.ssr.enabled', true); - - // Create an Inertia response and call withoutSSR() - $response = Inertia::render('Dashboard')->withoutSSR(); - - // Verify SSR was disabled - $this->assertFalse(config('inertia.ssr.enabled')); - - // Verify it returns the response (chainable) - $this->assertInstanceOf(Response::class, $response); - } - - public function test_without_ssr_is_chainable_with_other_methods(): void - { - // Start with SSR enabled - Config::set('inertia.ssr.enabled', true); - - $response = Inertia::render('Dashboard') - ->with('foo', 'bar') - ->withoutSSR() - ->with('baz', 'qux'); - - // Verify SSR was disabled - $this->assertFalse(config('inertia.ssr.enabled')); - - // Verify it's still a proper Inertia response - $this->assertInstanceOf(Response::class, $response); - } - - public function test_macros_can_override_each_other(): void - { - // Start with SSR disabled - Config::set('inertia.ssr.enabled', false); - - // Enable, then disable - Inertia::render('Index')->withSSR()->withoutSSR(); - $this->assertFalse(config('inertia.ssr.enabled')); - - // Disable, then enable - Inertia::render('Index')->withoutSSR()->withSSR(); - $this->assertTrue(config('inertia.ssr.enabled')); - } - - public function test_ssr_is_disabled_by_default_via_middleware(): void - { - // Verify SSR starts enabled in config (required for SSR server to run) - $this->assertTrue(config('inertia.ssr.enabled'), 'SSR should be enabled in config at boot'); - - // A route defined here rather than one of the application's own, so the test - // stays about SSR. Real routes accumulate guards from whichever modules are - // installed — /dashboard redirects once tenancy is present, for instance — and - // this test should not fail for reasons that have nothing to do with SSR. - Route::middleware('web')->get('/ssr-probe', fn () => Inertia::render('Index')); - - $response = $this->get('/ssr-probe'); - - $response->assertOk(); - - // After the middleware runs, SSR should be disabled - // (since the controller doesn't call ->withSSR()) - $this->assertFalse(config('inertia.ssr.enabled'), 'Middleware should disable SSR by default'); - } -} diff --git a/tests/Feature/LocalizationSettingsTest.php b/tests/Feature/LocalizationSettingsTest.php deleted file mode 100644 index 0d9b5c79..00000000 --- a/tests/Feature/LocalizationSettingsTest.php +++ /dev/null @@ -1,253 +0,0 @@ -get('/localization-probe', fn () => Inertia::render('Index')); - } - - public function test_fresh_install_offers_the_locales_that_ship(): void - { - $settings = app(LocalizationSettings::class); - - $this->assertSame(['en', 'pt_BR'], $settings->enabled_locales); - $this->assertSame('en', $settings->default_locale); - } - - public function test_available_locales_are_discovered_from_lang_directories(): void - { - $available = app(LocalizationSettings::class)->available(); - - // Both ship in core; pt_BR also ships inside several modules, which must not - // produce a duplicate. - $this->assertSame(['en', 'pt_BR'], array_keys($available)); - - // Named from config, so the selector shows the endonym rather than an intl string - // like "Portuguese (Brazil)". - $this->assertSame('Português', $available['pt_BR']); - } - - public function test_disabling_a_locale_removes_it_from_the_shared_prop(): void - { - $this->setEnabledLocales(['en'], 'en'); - - $this->get('/localization-probe')->assertInertia( - fn (AssertableInertia $page) => $page->where('locales', ['en' => 'English']) - ); - } - - public function test_default_locale_applies_when_the_visitor_has_not_chosen(): void - { - $this->setEnabledLocales(['en', 'pt_BR'], 'pt_BR'); - - $this->get('/localization-probe')->assertOk(); - - $this->assertSame('pt_BR', app()->getLocale()); - } - - public function test_a_session_locale_that_is_no_longer_enabled_is_ignored(): void - { - $this->setEnabledLocales(['en'], 'en'); - - $this->withSession(['locale' => 'pt_BR'])->get('/localization-probe')->assertOk(); - - $this->assertSame('en', app()->getLocale()); - } - - public function test_switching_to_an_enabled_locale_is_accepted(): void - { - $this->setEnabledLocales(['en', 'pt_BR'], 'en'); - - $this->post('/locale/pt_BR') - ->assertOk() - ->assertJson(['locale' => 'pt_BR']); - - $this->assertSame('pt_BR', session('locale')); - } - - public function test_switching_to_a_disabled_locale_is_rejected(): void - { - $this->setEnabledLocales(['en'], 'en'); - - $this->post('/locale/pt_BR') - ->assertStatus(400) - ->assertJson(['error' => 'Invalid locale']); - - $this->assertNull(session('locale')); - } - - public function test_switching_stores_the_language_on_the_signed_in_user(): void - { - $this->setEnabledLocales(['en', 'pt_BR'], 'en'); - - $user = User::factory()->create(); - - $this->actingAs($user)->post('/locale/pt_BR')->assertOk(); - - $this->assertSame('pt_BR', $user->fresh()->locale); - } - - public function test_a_stored_user_language_survives_a_new_session(): void - { - $this->setEnabledLocales(['en', 'pt_BR'], 'en'); - - $user = User::factory()->create(['locale' => 'pt_BR']); - - // No session locale at all: a different browser, or a session that has expired. - $this->actingAs($user)->get('/localization-probe')->assertOk(); - - $this->assertSame('pt_BR', app()->getLocale()); - } - - public function test_a_stored_user_language_outranks_the_session(): void - { - $this->setEnabledLocales(['en', 'pt_BR'], 'en'); - - $user = User::factory()->create(['locale' => 'pt_BR']); - - $this->actingAs($user) - ->withSession(['locale' => 'en']) - ->get('/localization-probe') - ->assertOk(); - - $this->assertSame('pt_BR', app()->getLocale()); - } - - public function test_a_stored_user_language_that_is_no_longer_enabled_falls_back(): void - { - $user = User::factory()->create(['locale' => 'pt_BR']); - - $this->setEnabledLocales(['en'], 'en'); - - $this->actingAs($user)->get('/localization-probe')->assertOk(); - - $this->assertSame('en', app()->getLocale()); - } - - public function test_a_user_language_drives_notifications(): void - { - $this->setEnabledLocales(['en', 'pt_BR'], 'en'); - - $user = User::factory()->create(['locale' => 'pt_BR']); - - // The contract Laravel reads when sending, which is what carries the choice - // beyond the request it was made in. - $this->assertSame('pt_BR', $user->preferredLocale()); - } - - public function test_a_user_who_never_chose_expresses_no_preference(): void - { - $user = User::factory()->create(['locale' => null]); - - // Null leaves withLocale() alone, so a notification sent mid-request stays in the - // language the visitor is actually reading. - $this->assertNull($user->preferredLocale()); - } - - public function test_a_user_language_that_is_no_longer_enabled_expresses_no_preference(): void - { - $user = User::factory()->create(['locale' => 'pt_BR']); - - $this->setEnabledLocales(['en'], 'en'); - - // Otherwise the browser would say English and the email would still say Portuguese. - $this->assertNull($user->fresh()->preferredLocale()); - } - - public function test_enabled_never_resolves_to_no_language(): void - { - $this->setEnabledLocales([], 'en'); - - // An empty setting would otherwise leave the application rendering raw keys. - $this->assertSame(['en' => 'English'], app(LocalizationSettings::class)->enabled()); - } - - public function test_a_default_locale_that_is_no_longer_enabled_falls_back(): void - { - $this->setEnabledLocales(['pt_BR'], 'en'); - - $this->get('/localization-probe')->assertOk(); - - $this->assertSame('pt_BR', app()->getLocale()); - } - - public function test_administrator_can_save_localization_settings(): void - { - $this->actingAsAdmin(); - - Livewire::test(LocalizationSettingsPage::class) - ->assertSchemaStateSet([ - 'enabled_locales' => ['en', 'pt_BR'], - 'default_locale' => 'en', - ]) - ->fillForm([ - 'enabled_locales' => ['pt_BR'], - 'default_locale' => 'pt_BR', - ]) - ->call('save') - ->assertHasNoFormErrors() - ->assertNotified(); - - app()->forgetInstance(LocalizationSettings::class); - $settings = app(LocalizationSettings::class); - - $this->assertSame(['pt_BR'], $settings->enabled_locales); - $this->assertSame('pt_BR', $settings->default_locale); - } - - public function test_at_least_one_language_must_stay_enabled(): void - { - $this->actingAsAdmin(); - - Livewire::test(LocalizationSettingsPage::class) - ->fillForm([ - 'enabled_locales' => [], - 'default_locale' => 'en', - ]) - ->call('save') - ->assertHasFormErrors(['enabled_locales']); - - app()->forgetInstance(LocalizationSettings::class); - - $this->assertSame(['en', 'pt_BR'], app(LocalizationSettings::class)->enabled_locales); - } - - /** - * @param list $locales - */ - private function setEnabledLocales(array $locales, string $default): void - { - $settings = app(LocalizationSettings::class); - $settings->enabled_locales = $locales; - $settings->default_locale = $default; - $settings->save(); - - app()->forgetInstance(LocalizationSettings::class); - } - - private function actingAsAdmin(): void - { - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - } -} diff --git a/tests/Feature/RouteAccessTest.php b/tests/Feature/RouteAccessTest.php new file mode 100644 index 00000000..75251264 --- /dev/null +++ b/tests/Feature/RouteAccessTest.php @@ -0,0 +1,71 @@ + + */ + public static function guardedRoutes(): array + { + return [ + 'dashboard' => ['dashboard'], + 'settings' => ['settings'], + ]; + } + + /** + * @return array + */ + public static function publicRoutes(): array + { + return [ + 'index' => ['index'], + 'privacy' => ['privacy'], + 'terms' => ['terms'], + 'locale' => ['locale'], + ]; + } + + #[DataProvider('guardedRoutes')] + public function test_signed_in_pages_are_behind_authentication(string $name): void + { + $middleware = Route::getRoutes()->getByName($name)->gatherMiddleware(); + + $this->assertContains('auth', $middleware); + $this->assertContains('verified', $middleware); + + // Which roles, rather than that there are roles, is left unpinned: the set is + // expected to change (see sc-690), and this test has nothing to say about it. + $this->assertNotEmpty( + array_filter($middleware, fn (string $m): bool => str_starts_with($m, 'role:')), + "Route [{$name}] should carry a role guard.", + ); + } + + #[DataProvider('publicRoutes')] + public function test_public_pages_are_reachable_without_signing_in(string $name): void + { + $middleware = Route::getRoutes()->getByName($name)->gatherMiddleware(); + + $this->assertNotContains('auth', $middleware); + } +} diff --git a/tests/Feature/SettingsInfrastructureTest.php b/tests/Feature/SettingsInfrastructureTest.php deleted file mode 100644 index fcbacf24..00000000 --- a/tests/Feature/SettingsInfrastructureTest.php +++ /dev/null @@ -1,138 +0,0 @@ -corePackagePath('composer.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - - $this->assertArrayHasKey( - 'filament/spatie-laravel-settings-plugin', - $coreComposer['require'], - 'The settings plugin must be required by saucebase/core, not by a module.', - ); - - $this->assertTrue( - class_exists(SpatieLaravelSettingsPluginServiceProvider::class), - 'The settings plugin is declared but not installed.', - ); - } - - /** - * Resolve a path inside the installed saucebase/core package. - * - * Uses the class map rather than a hardcoded vendor path, so this works whether core - * is symlinked from packages/ during development or installed from Packagist. - */ - private function corePackagePath(string $path): string - { - $src = dirname((new ReflectionClass(SettingsPage::class))->getFileName()); - - return dirname($src, 2).'/'.$path; - } - - public function test_root_application_migrations_create_the_settings_repository(): void - { - $this->artisan('migrate:fresh', [ - '--path' => 'database/migrations', - '--no-interaction' => true, - ])->assertSuccessful(); - - $settingsTable = config('settings.repositories.database.table') ?? 'settings'; - - $this->assertTrue(Schema::hasTable($settingsTable)); - } - - /** - * The base class exists so the constraint is set once rather than remembered three - * times. Without a width every settings page fills the viewport, which stretches a - * single-column form across a wide monitor. - */ - public function test_the_shared_settings_page_constrains_its_width(): void - { - $width = (new ReflectionClass(SettingsPage::class)) - ->getDefaultProperties()['maxContentWidth'] ?? null; - - $this->assertInstanceOf(Width::class, $width); - $this->assertNotSame(Width::Full, $width); - } - - public function test_settings_navigation_sort_is_bounded_at_the_integer_limit(): void - { - $navigationSort = new ReflectionClass(SettingsPage::class)->getProperty('navigationSort'); - $originalNavigationSort = $navigationSort->getValue(); - - try { - $navigationSort->setValue(null, 1000); - $this->assertSame(PHP_INT_MAX, SettingsPage::getNavigationSort()); - - $navigationSort->setValue(null, 1001); - $this->assertSame(PHP_INT_MAX, SettingsPage::getNavigationSort()); - } finally { - $navigationSort->setValue(null, $originalNavigationSort); - } - } - - /** - * Discovered rather than listed: a settings page added by a future module has to opt - * into the convention, and the only way this test notices is by finding it. - */ - public function test_every_module_settings_page_extends_the_shared_base(): void - { - $pages = $this->moduleSettingsPages(); - - $invalidPages = array_values(array_filter( - $pages, - fn (string $page): bool => ! is_subclass_of($page, SettingsPage::class), - )); - - $this->assertSame( - [], - $invalidPages, - implode(', ', $invalidPages).' must extend '.SettingsPage::class.'.', - ); - } - - /** - * @return array - */ - private function moduleSettingsPages(): array - { - $pages = []; - - foreach (glob(base_path('modules/*/src/Filament/Pages/*.php')) ?: [] as $file) { - $module = basename(dirname($file, 4)); - $class = 'Modules\\'.str($module)->studly().'\\Filament\\Pages\\'.basename($file, '.php'); - - if (class_exists($class) && is_subclass_of($class, FilamentSettingsPage::class)) { - $pages[] = $class; - } - } - - return $pages; - } -} diff --git a/tests/Feature/SettingsModalTest.php b/tests/Feature/SettingsModalTest.php deleted file mode 100644 index af4ba7b6..00000000 --- a/tests/Feature/SettingsModalTest.php +++ /dev/null @@ -1,236 +0,0 @@ -instance(SectionRegistry::class, new class extends SectionRegistry - { - public function __construct() {} - - public function all(): Collection - { - return collect([ - new AlphaSection, - new BetaSection, - ]); - } - }); - } - - /** - * Asserted on the middleware rather than by making the request: where the - * unauthenticated visitor is *sent* is the auth module's business, and core has - * no login route of its own to be redirected to. - */ - public function test_the_settings_route_is_behind_authentication(): void - { - $middleware = Route::getRoutes()->getByName('settings')->gatherMiddleware(); - - $this->assertContains('auth', $middleware); - } - - public function test_it_renders_the_settings_modal_component(): void - { - $response = $this->actingAs($this->createUser()) - ->withHeader('X-InertiaUI-Modal', 'true') - ->get(route('settings')) - ->assertOk(); - - $this->assertSame('Settings', $response->viewData('page')['component']); - } - - public function test_it_defaults_to_the_first_registered_section(): void - { - $response = $this->actingAs($this->createUser()) - ->withHeader('X-InertiaUI-Modal', 'true') - ->get(route('settings')) - ->assertOk(); - - $this->assertSame('alpha', $response->viewData('page')['props']['activeSection']); - } - - public function test_it_resolves_props_for_the_requested_section(): void - { - $response = $this->actingAs($this->createUser()) - ->withHeader('X-InertiaUI-Modal', 'true') - ->get(route('settings', ['section' => 'beta'])) - ->assertOk(); - - $props = $response->viewData('page')['props']; - - $this->assertSame('beta', $props['activeSection']); - $this->assertSame(['value' => 'beta'], $props['sectionProps']['beta']); - } - - /** - * Only the section being shown pays for its data; the rest are deferred, so - * opening the modal never runs every other section's queries. - */ - public function test_it_defers_the_sections_that_are_not_shown(): void - { - $response = $this->actingAs($this->createUser()) - ->withHeader('X-InertiaUI-Modal', 'true') - ->get(route('settings', ['section' => 'beta'])) - ->assertOk(); - - $this->assertArrayNotHasKey('alpha', $response->viewData('page')['props']['sectionProps']); - } - - /** - * The sidebar gets what it needs to list the sections, and nothing else. - * - * A section's props are resolved server-side and arrive as modal props, so a - * section object must never travel to the front-end — this pins the shape that - * does. - */ - public function test_it_shares_only_the_sidebar_view_of_each_section(): void - { - $response = $this->actingAs($this->createUser())->get(route('index')); - - $sections = $response->inertiaProps('settings.sections'); - - $this->assertSame(['slug', 'title', 'icon', 'component'], array_keys($sections[0])); - $this->assertSame( - [['alpha', 'Test::Alpha'], ['beta', 'Test::Beta']], - array_map(fn (array $s): array => [$s['slug'], $s['component']], $sections), - ); - } - - /** - * Guards the SsrState fix in AppServiceProvider. - * - * Nothing in the app uses `baseRoute()` today, but the package ships it as a - * headline feature, so a downstream modal that opts in must render its own - * URL rather than the base route's. - */ - public function test_a_modal_with_a_base_route_renders_its_own_url(): void - { - Route::middleware(['web', 'auth'])->get( - '/__base-route-modal', - fn () => Inertia::modal('Settings', [ - 'activeSection' => null, - 'sectionProps' => [], - ])->baseRoute('index'), - ); - - $html = $this->actingAs($this->createUser()) - ->get('/__base-route-modal') - ->assertOk() - ->getContent(); - - $page = $this->renderedPage($html); - - // The base route supplies the component behind the modal... - $this->assertSame('Index', $page['component']); - - // ...while the URL stays the modal's, which is what tells the front-end - // the modal belongs to this page instead of closing it. - $this->assertSame('/__base-route-modal', $page['url']); - $this->assertSame('Settings', $page['props']['_inertiaui_modal']['component']); - } - - /** - * The page payload as the browser receives it. - * - * Deliberately not `viewData('page')`: that is the array the modal package - * itself rewrites, so reading it would pass whether or not the rewrite ever - * reaches the HTML — which under Inertia v3 it does not, because the payload - * is rendered from `SsrState`. Asserting on the markup is the only way this - * test can fail when the fix is removed. - * - * @return array - */ - private function renderedPage(string $html): array - { - preg_match('#]*data-page="app"[^>]*>(.*?)#s', $html, $matches); - - return json_decode(html_entity_decode($matches[1] ?? '{}'), true); - } -} - -class AlphaSection extends SettingsSection -{ - public function slug(): string - { - return 'alpha'; - } - - public function title(): string - { - return 'Alpha'; - } - - public function icon(): ?string - { - return 'alpha'; - } - - public function order(): int - { - return 10; - } - - public function component(): string - { - return 'Test::Alpha'; - } - - public function props(): array - { - return ['value' => 'alpha']; - } -} - -class BetaSection extends AlphaSection -{ - public function slug(): string - { - return 'beta'; - } - - public function title(): string - { - return 'Beta'; - } - - public function icon(): ?string - { - return 'beta'; - } - - public function order(): int - { - return 20; - } - - public function component(): string - { - return 'Test::Beta'; - } - - public function props(): array - { - return ['value' => 'beta']; - } -} diff --git a/tests/Feature/SettingsNavigationTest.php b/tests/Feature/SettingsNavigationTest.php deleted file mode 100644 index 00871b46..00000000 --- a/tests/Feature/SettingsNavigationTest.php +++ /dev/null @@ -1,58 +0,0 @@ -create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - $panel = Filament::getPanel('admin'); - - Filament::setCurrentPanel($panel); - Filament::bootCurrentPanel(); - - $registeredSettingsGroup = collect($panel->getNavigationGroups()) - ->contains(fn (NavigationGroup|string $group): bool => $group instanceof NavigationGroup - ? $group->getLabel() === __('Settings') - : $group === __('Settings')); - - $navigation = collect(Filament::getNavigation())->values(); - - $settingsGroup = $navigation - ->first(fn (NavigationGroup $group): bool => $group->getLabel() === __('Settings')); - - $this->assertFalse($registeredSettingsGroup); - $this->assertInstanceOf(NavigationGroup::class, $settingsGroup); - $this->assertContains( - __('General'), - collect($settingsGroup->getItems())->map->getLabel()->all(), - ); - } - - public function test_settings_pages_do_not_render_duplicate_sub_navigation(): void - { - $admin = User::factory()->create(); - $admin->assignRole(Role::ADMIN); - - $this->actingAs($admin); - - $page = app(GeneralSettings::class); - - $this->assertSame([], $page->getSubNavigation()); - } -} diff --git a/tests/Unit/Console/Commands/RecipeToModuleCommandTest.php b/tests/Unit/Console/Commands/RecipeToModuleCommandTest.php deleted file mode 100644 index 0640872e..00000000 --- a/tests/Unit/Console/Commands/RecipeToModuleCommandTest.php +++ /dev/null @@ -1,270 +0,0 @@ -command = new TestableRecipeToModuleCommand; - - $this->files = new Filesystem; - $this->tmpDir = sys_get_temp_dir().'/saucebase-recipe-test-'.uniqid(); - $jsRoot = $this->tmpDir.'/modules/test-module/resources/js'; - $this->files->makeDirectory($jsRoot, 0755, true); - file_put_contents($jsRoot.'/app.ts', "export * from './vue/app';\n"); - - exec("git -C {$this->tmpDir} init -q 2>/dev/null"); - exec("git -C {$this->tmpDir} config user.email 'test@test.com' 2>/dev/null"); - exec("git -C {$this->tmpDir} config user.name 'Test' 2>/dev/null"); - - $this->originalBasePath = base_path(); - app()->setBasePath($this->tmpDir); - } - - protected function tearDown(): void - { - app()->setBasePath($this->originalBasePath); - $this->files->deleteDirectory($this->tmpDir); - parent::tearDown(); - } - - // ── Name normalization ──────────────────────────────────────────────────── - - #[DataProvider('moduleNameInputProvider')] - public function test_set_module_names_normalizes_input(string $input, string $expectedName, string $expectedFolder): void - { - $this->command->setModuleNames($input); - - $this->assertSame($expectedName, $this->command->moduleName); - $this->assertSame($expectedFolder, $this->command->moduleFolder); - } - - public static function moduleNameInputProvider(): array - { - return [ - 'kebab-case input' => ['invoice-test', 'InvoiceTest', 'invoice-test'], - 'StudlyCase input' => ['InvoiceTest', 'InvoiceTest', 'invoice-test'], - 'single word kebab' => ['auth', 'Auth', 'auth'], - 'single word studly' => ['Auth', 'Auth', 'auth'], - 'multi-word kebab' => ['super-admin-panel', 'SuperAdminPanel', 'super-admin-panel'], - 'multi-word studly' => ['SuperAdminPanel', 'SuperAdminPanel', 'super-admin-panel'], - ]; - } - - // ── Placeholder map ─────────────────────────────────────────────────────── - - public function test_placeholders_module_base_variants(): void - { - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('InvoiceTest', $map['{Module}']); - $this->assertSame('invoicetest', $map['{module}']); - $this->assertSame('Invoice_Test', $map['{Module_}']); - $this->assertSame('invoice_test', $map['{module_}']); - $this->assertSame('Invoice-Test', $map['{Module-}']); - $this->assertSame('invoice-test', $map['{module-}']); - } - - public function test_placeholders_module_camel_and_studly(): void - { - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('invoiceTest', $map['{moduleCamel}']); - $this->assertSame('InvoiceTest', $map['{ModuleStudly}']); - $this->assertSame('InvoiceTest', $map['{moduleStudly}']); - } - - public function test_placeholders_module_human_readable(): void - { - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('Invoice Test', $map['{Module }']); - $this->assertSame('invoice test', $map['{module }']); - $this->assertSame('Invoice Test', $map['{ModuleTitle}']); - $this->assertSame('Invoice Test', $map['{module_title}']); - } - - public function test_placeholders_module_plural(): void - { - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('invoice tests', $map['{module_plural}']); - $this->assertSame('invoice_tests', $map['{module_plural_snake}']); - $this->assertSame('invoice-tests', $map['{module_plural_kebab}']); - } - - public function test_placeholders_model_variants(): void - { - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('InvoiceTest', $map['{Model}']); - $this->assertSame('invoicetest', $map['{model}']); - $this->assertSame('invoice-test', $map['{model-}']); - $this->assertSame('invoice_test', $map['{model_}']); - } - - public function test_placeholders_js_friendly_tokens(): void - { - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('InvoiceTest', $map['___Module___']); - $this->assertSame('invoicetest', $map['___module___']); - } - - public function test_placeholders_namespace_tokens(): void - { - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('Modules', $map['{MODULE_NAMESPACE}']); - $this->assertSame('Modules', $map['___MODULE_NAMESPACE___']); - } - - public function test_placeholders_composer_vendor(): void - { - $this->command->composerVendor = 'saucebase'; - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('saucebase', $map['{COMPOSER_VENDOR}']); - } - - public function test_placeholders_composer_type(): void - { - $this->command->composerType = 'library'; - $map = $this->command->placeholders('InvoiceTest'); - - $this->assertSame('library', $map['{COMPOSER_TYPE}']); - } - - public function test_placeholders_single_word_module(): void - { - $map = $this->command->placeholders('Auth'); - - $this->assertSame('Auth', $map['{Module}']); - $this->assertSame('auth', $map['{module}']); - $this->assertSame('auth', $map['{module-}']); - $this->assertSame('auth', $map['{module_}']); - $this->assertSame('Auth', $map['___Module___']); - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - private function writeFrontendJson(array $data): void - { - file_put_contents($this->tmpDir.'/frontend.json', json_encode($data)); - } - - private function makeCommand(): TestableRecipeToModuleCommand - { - $cmd = new TestableRecipeToModuleCommand; - $cmd->moduleConfigPath = $this->tmpDir.'/modules/'; - $cmd->moduleFolder = 'test-module'; - $cmd->setOutput(new OutputStyle(new ArrayInput([]), new NullOutput)); - - return $cmd; - } - - private function jsRoot(): string - { - return $this->tmpDir.'/modules/test-module/resources/js'; - } - - // ── Frontend framework: early exit ──────────────────────────────────────── - - public function test_aborts_when_no_framework_is_selected(): void - { - $this->writeFrontendJson(['framework' => null]); - - $this->artisan('saucebase:recipe', ['module' => 'TestExit']) - ->expectsPromptsError('No frontend framework selected. Run `saucebase stack vue` or `saucebase stack react` first.'); - - $this->assertDirectoryDoesNotExist($this->tmpDir.'/modules/test-exit'); - } - - // ── writeModuleEntryPoint: install mode ─────────────────────────────────── - - public function test_install_mode_vue_keeps_app_ts(): void - { - $this->makeCommand()->writeModuleEntryPoint('vue', false); - - $this->assertFileExists($this->jsRoot().'/app.ts'); - } - - public function test_install_mode_react_removes_stale_app_ts(): void - { - $this->makeCommand()->writeModuleEntryPoint('react', false); - - $this->assertFileDoesNotExist($this->jsRoot().'/app.ts'); - } - - // ── writeModuleEntryPoint: dev mode ─────────────────────────────────────── - - public function test_dev_mode_react_writes_proxy(): void - { - $this->files->makeDirectory($this->jsRoot().'/react', 0755, true); - - $this->makeCommand()->writeModuleEntryPoint('react', true); - - $this->assertStringContainsString( - "export * from './react/app'", - (string) file_get_contents($this->jsRoot().'/app.ts') - ); - } - - public function test_dev_mode_vue_writes_proxy(): void - { - $this->files->makeDirectory($this->jsRoot().'/vue', 0755, true); - - $this->makeCommand()->writeModuleEntryPoint('vue', true); - - $this->assertStringContainsString( - "export * from './vue/app'", - (string) file_get_contents($this->jsRoot().'/app.ts') - ); - } -} From 20498791b1a05d13d391f69eadc724524fb943d6 Mon Sep 17 00:00:00 2001 From: roble Date: Thu, 10 Sep 2026 19:27:46 +0100 Subject: [PATCH 4/8] chore: remove obsolete configuration files and clean up project structure --- config/app-modules.php | 94 --------- config/cors.php | 34 ---- config/filament.php | 132 ------------- config/inertia.php | 138 ------------- config/media-library.php | 317 ------------------------------ config/permission.php | 206 ------------------- config/saucebase.php | 8 - config/sluggable.php | 154 --------------- config/typescript-transformer.php | 95 --------- config/ziggy.php | 4 - 10 files changed, 1182 deletions(-) delete mode 100644 config/app-modules.php delete mode 100644 config/cors.php delete mode 100644 config/filament.php delete mode 100644 config/inertia.php delete mode 100644 config/media-library.php delete mode 100644 config/permission.php delete mode 100644 config/saucebase.php delete mode 100644 config/sluggable.php delete mode 100644 config/typescript-transformer.php delete mode 100644 config/ziggy.php diff --git a/config/app-modules.php b/config/app-modules.php deleted file mode 100644 index 5e5dbd92..00000000 --- a/config/app-modules.php +++ /dev/null @@ -1,94 +0,0 @@ - 'Modules', - - /* - |-------------------------------------------------------------------------- - | Composer "Vendor" Name - |-------------------------------------------------------------------------- - | - | This is the prefix used for your composer.json file. This should be the - | kebab-case version of your module namespace (if left null, we will - | generate the kebab-case version for you). - | - */ - - 'modules_vendor' => null, - - /* - |-------------------------------------------------------------------------- - | Modules Directory - |-------------------------------------------------------------------------- - | - | If you want to install modules in a custom directory, you can do so here. - | Keeping the default `app-modules/` directory is highly recommended, - | though, as it keeps your modules near the rest of your application code - | in an alpha-sorted directory listing. - | - */ - - 'modules_directory' => 'modules', - - /* - |-------------------------------------------------------------------------- - | Base Test Case - |-------------------------------------------------------------------------- - | - | This is the base TestCase class name that auto-generated Tests should - | extend. By default it assumes the default \Tests\TestCase exists. - | - */ - - 'tests_base' => 'Tests\TestCase', - - /* - |-------------------------------------------------------------------------- - | Custom Stubs - |-------------------------------------------------------------------------- - | - | If you would like to use your own custom stubs for new modules, you can - | configure those here. This should be an array where the key is the path - | relative to the module and the value is the absolute path to the stub - | stub file. Destination paths and contents support placeholders. See the - | README.md file for more information. - | - | For example: - | - | 'stubs' => [ - | 'src/Providers/StubClassNamePrefixServiceProvider.php' => base_path('stubs/app-modules/ServiceProvider.php'), - | ], - */ - - 'stubs' => null, - - /* - |-------------------------------------------------------------------------- - | Custom override of event discovery - |-------------------------------------------------------------------------- - | - | This is a custom override of the event discovery feature. If you want to - | disable event discovery, set this to false. If you want to enable event - | discovery, set this to true. We will still check the app namespace for - | the presence of event discovery. - */ - - 'should_discover_events' => null, -]; diff --git a/config/cors.php b/config/cors.php deleted file mode 100644 index 8a39e6da..00000000 --- a/config/cors.php +++ /dev/null @@ -1,34 +0,0 @@ - ['api/*', 'sanctum/csrf-cookie'], - - 'allowed_methods' => ['*'], - - 'allowed_origins' => ['*'], - - 'allowed_origins_patterns' => [], - - 'allowed_headers' => ['*'], - - 'exposed_headers' => [], - - 'max_age' => 0, - - 'supports_credentials' => false, - -]; diff --git a/config/filament.php b/config/filament.php deleted file mode 100644 index 5ae39939..00000000 --- a/config/filament.php +++ /dev/null @@ -1,132 +0,0 @@ - [ - - // 'echo' => [ - // 'broadcaster' => 'pusher', - // 'key' => env('VITE_PUSHER_APP_KEY'), - // 'cluster' => env('VITE_PUSHER_APP_CLUSTER'), - // 'wsHost' => env('VITE_PUSHER_HOST'), - // 'wsPort' => env('VITE_PUSHER_PORT'), - // 'wssPort' => env('VITE_PUSHER_PORT'), - // 'authEndpoint' => '/broadcasting/auth', - // 'disableStats' => true, - // 'encrypted' => true, - // 'forceTLS' => true, - // ], - - ], - - /* - |-------------------------------------------------------------------------- - | Default Filesystem Disk - |-------------------------------------------------------------------------- - | - | This is the storage disk Filament will use to store files. You may use - | any of the disks defined in the `config/filesystems.php`. - | - */ - - 'default_filesystem_disk' => env('FILESYSTEM_DISK', 'local'), - - /* - |-------------------------------------------------------------------------- - | Assets Path - |-------------------------------------------------------------------------- - | - | This is the directory where Filament's assets will be published to. It - | is relative to the `public` directory of your Laravel application. - | - | After changing the path, you should run `php artisan filament:assets`. - | - */ - - 'assets_path' => null, - - /* - |-------------------------------------------------------------------------- - | Cache Path - |-------------------------------------------------------------------------- - | - | This is the directory that Filament will use to store cache files that - | are used to optimize the registration of components. - | - | After changing the path, you should run `php artisan filament:cache-components`. - | - */ - - 'cache_path' => base_path('bootstrap/cache/filament'), - - /* - |-------------------------------------------------------------------------- - | Livewire Loading Delay - |-------------------------------------------------------------------------- - | - | This sets the delay before loading indicators appear. - | - | Setting this to 'none' makes indicators appear immediately, which can be - | desirable for high-latency connections. Setting it to 'default' applies - | Livewire's standard 200ms delay. - | - */ - - 'livewire_loading_delay' => 'default', - - /* - |-------------------------------------------------------------------------- - | File Generation - |-------------------------------------------------------------------------- - | - | Artisan commands that generate files can be configured here by setting - | configuration flags that will impact their location or content. - | - | Often, this is useful to preserve file generation behavior from a - | previous version of Filament, to ensure consistency between older and - | newer generated files. These flags are often documented in the upgrade - | guide for the version of Filament you are upgrading to. - | - */ - - 'file_generation' => [ - 'flags' => [], - ], - - /* - |-------------------------------------------------------------------------- - | System Route Prefix - |-------------------------------------------------------------------------- - | - | This is the prefix used for the system routes that Filament registers, - | such as the routes for downloading exports and failed import rows. - | - */ - - 'system_route_prefix' => 'filament', - - /*|-------------------------------------------------------------------------- - | Modules - |-------------------------------------------------------------------------- - | Here you may configure settings for Filament's module system. - - |*/ - 'modules' => [ - 'clusters' => [ - 'enabled' => true, - 'use-top-navigation' => false, - ], - ], -]; diff --git a/config/inertia.php b/config/inertia.php deleted file mode 100644 index d7213b06..00000000 --- a/config/inertia.php +++ /dev/null @@ -1,138 +0,0 @@ - [ - - 'enabled' => (bool) env('INERTIA_SSR_ENABLED', true), - - 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), - - 'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true), - - // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), - - /* - |-------------------------------------------------------------------------- - | SSR Error Handling - |-------------------------------------------------------------------------- - | - | When SSR rendering fails, Inertia gracefully falls back to client-side - | rendering. Set throw_on_error to true to throw an exception instead. - | This is useful for E2E testing where you want SSR errors to fail loudly. - | - | You can also listen for the Inertia\Ssr\SsrRenderFailed event to handle - | failures in your own way (e.g., logging, error tracking service). - | - */ - - 'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false), - - ], - - /* - |-------------------------------------------------------------------------- - | Pages - |-------------------------------------------------------------------------- - | - | Set `ensure_pages_exist` to true if you want to enforce that Inertia page - | components exist on disk when rendering a page. This is useful for - | catching missing or misnamed components. - | - | The `paths` and `extensions` options define where to look for page - | components and which file extensions to consider. - | - */ - - 'pages' => [ - - 'ensure_pages_exist' => false, - - 'paths' => [ - - resource_path('js/pages'), - - ], - - 'extensions' => [ - - 'js', - 'jsx', - 'svelte', - 'ts', - 'tsx', - 'vue', - - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Testing - |-------------------------------------------------------------------------- - | - | When using `assertInertia`, the assertion attempts to locate the - | component as a file relative to the `pages.paths` AND with any of - | the `pages.extensions` specified above. - | - | You can disable this behavior by setting `ensure_pages_exist` - | to false. - | - */ - - 'testing' => [ - - 'ensure_pages_exist' => true, - - ], - - /* - |-------------------------------------------------------------------------- - | Expose Shared Prop Keys - |-------------------------------------------------------------------------- - | - | When enabled, each page response includes a `sharedProps` metadata key - | listing the top-level prop keys that were registered via `Inertia::share`. - | The frontend can use this to carry shared props over during instant visits. - | - */ - - 'expose_shared_prop_keys' => true, - - /* - |-------------------------------------------------------------------------- - | History - |-------------------------------------------------------------------------- - | - | Enable `encrypt` to encrypt page data before it is stored in the - | browser's history state, preventing sensitive information from - | being accessible after logout. Can also be enabled per-request - | or via the `inertia.encrypt` middleware. - | - */ - - 'history' => [ - - 'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false), - - ], - -]; diff --git a/config/media-library.php b/config/media-library.php deleted file mode 100644 index d9a05772..00000000 --- a/config/media-library.php +++ /dev/null @@ -1,317 +0,0 @@ - env('MEDIA_DISK', 'public'), - - /* - * The maximum file size of an item in bytes. - * Adding a larger file will result in an exception. - */ - 'max_file_size' => 1024 * 1024 * 10, // 10MB - - /* - * This queue connection will be used to generate derived and responsive images. - * Leave empty to use the default queue connection. - */ - 'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'), - - /* - * This queue will be used to generate derived and responsive images. - * Leave empty to use the default queue. - */ - 'queue_name' => env('MEDIA_QUEUE', ''), - - /* - * By default all conversions will be performed on a queue. - */ - 'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true), - - /* - * Should database transactions be run after database commits? - */ - 'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true), - - /* - * The fully qualified class name of the media model. - */ - 'media_model' => Media::class, - - /* - * The fully qualified class name of the media observer. - */ - 'media_observer' => MediaObserver::class, - - /* - * When enabled, media collections will be serialised using the default - * laravel model serialization behaviour. - * - * Keep this option disabled if using Media Library Pro components (https://medialibrary.pro) - */ - 'use_default_collection_serialization' => false, - - /* - * The fully qualified class name of the model used for temporary uploads. - * - * This model is only used in Media Library Pro (https://medialibrary.pro) - */ - 'temporary_upload_model' => TemporaryUpload::class, - - /* - * When enabled, Media Library Pro will only process temporary uploads that were uploaded - * in the same session. You can opt to disable this for stateless usage of - * the pro components. - */ - 'enable_temporary_uploads_session_affinity' => true, - - /* - * When enabled, Media Library pro will generate thumbnails for uploaded file. - */ - 'generate_thumbnails_for_temporary_uploads' => true, - - /* - * This is the class that is responsible for naming generated files. - */ - 'file_namer' => DefaultFileNamer::class, - - /* - * The class that contains the strategy for determining a media file's path. - */ - 'path_generator' => DefaultPathGenerator::class, - - /* - * The class that contains the strategy for determining how to remove files. - */ - 'file_remover_class' => DefaultFileRemover::class, - - /* - * Here you can specify which path generator should be used for the given class. - */ - 'custom_path_generators' => [ - // Model::class => PathGenerator::class - // or - // 'model_morph_alias' => PathGenerator::class - ], - - /* - * When urls to files get generated, this class will be called. Use the default - * if your files are stored locally above the site root or on s3. - */ - 'url_generator' => DefaultUrlGenerator::class, - - /* - * Moves media on updating to keep path consistent. Enable it only with a custom - * PathGenerator that uses, for example, the media UUID. - */ - 'moves_media_on_update' => false, - - /* - * Whether to activate versioning when urls to files get generated. - * When activated, this attaches a ?v=xx query string to the URL. - */ - 'version_urls' => false, - - /* - * The media library will try to optimize all converted images by removing - * metadata and applying a little bit of compression. These are - * the optimizers that will be used by default. - */ - 'image_optimizers' => [ - Jpegoptim::class => [ - '-m85', // set maximum quality to 85% - '--force', // ensure that progressive generation is always done also if a little bigger - '--strip-all', // this strips out all text information such as comments and EXIF data - '--all-progressive', // this will make sure the resulting image is a progressive one - ], - Pngquant::class => [ - '--force', // required parameter for this package - ], - Optipng::class => [ - '-i0', // this will result in a non-interlaced, progressive scanned image - '-o2', // this set the optimization level to two (multiple IDAT compression trials) - '-quiet', // required parameter for this package - ], - Svgo::class => [ - '--disable=cleanupIDs', // disabling because it is known to cause troubles - ], - Gifsicle::class => [ - '-b', // required parameter for this package - '-O3', // this produces the slowest but best results - ], - Cwebp::class => [ - '-m 6', // for the slowest compression method in order to get the best compression. - '-pass 10', // for maximizing the amount of analysis pass. - '-mt', // multithreading for some speed improvements. - '-q 90', // quality factor that brings the least noticeable changes. - ], - Avifenc::class => [ - '-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63). - '-j all', // number of jobs (worker threads, "all" uses all available cores). - '--min 0', // min quantizer for color (0-63). - '--max 63', // max quantizer for color (0-63). - '--minalpha 0', // min quantizer for alpha (0-63). - '--maxalpha 63', // max quantizer for alpha (0-63). - '-a end-usage=q', // rate control mode set to Constant Quality mode. - '-a tune=ssim', // SSIM as tune the encoder for distortion metric. - ], - ], - - /* - * These generators will be used to create an image of media files. - */ - 'image_generators' => [ - Image::class, - Webp::class, - Avif::class, - Pdf::class, - Svg::class, - Video::class, - ], - - /* - * The path where to store temporary files while performing image conversions. - * If set to null, storage_path('media-library/temp') will be used. - */ - 'temporary_directory_path' => null, - - /* - * The engine that should perform the image conversions. - * Should be either `gd` or `imagick`. - */ - 'image_driver' => env('IMAGE_DRIVER', 'gd'), - - /* - * FFMPEG & FFProbe binaries paths, only used if you try to generate video - * thumbnails and have installed the php-ffmpeg/php-ffmpeg composer - * dependency. - */ - 'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'), - 'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'), - - /* - * Here you can override the class names of the jobs used by this package. Make sure - * your custom jobs extend the ones provided by the package. - */ - 'jobs' => [ - 'perform_conversions' => PerformConversionsJob::class, - 'generate_responsive_images' => GenerateResponsiveImagesJob::class, - ], - - /* - * When using the addMediaFromUrl method you may want to replace the default downloader. - * This is particularly useful when the url of the image is behind a firewall and - * need to add additional flags, possibly using curl. - */ - 'media_downloader' => DefaultDownloader::class, - - /* - * When using the addMediaFromUrl method the SSL is verified by default. - * This is option disables SSL verification when downloading remote media. - * Please note that this is a security risk and should only be false in a local environment. - */ - 'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true), - - /* - * The default lifetime in minutes for temporary urls. - * This is used when you call the `getLastTemporaryUrl` or `getLastTemporaryUrl` method on a media item. - */ - 'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5), - - 'remote' => [ - /* - * Any extra headers that should be included when uploading media to - * a remote disk. Even though supported headers may vary between - * different drivers, a sensible default has been provided. - * - * Supported by S3: CacheControl, Expires, StorageClass, - * ServerSideEncryption, Metadata, ACL, ContentEncoding - */ - 'extra_headers' => [ - 'CacheControl' => 'max-age=604800', - ], - ], - - 'responsive_images' => [ - /* - * This class is responsible for calculating the target widths of the responsive - * images. By default we optimize for filesize and create variations that each are 30% - * smaller than the previous one. More info in the documentation. - * - * https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images - */ - 'width_calculator' => FileSizeOptimizedWidthCalculator::class, - - /* - * By default rendering media to a responsive image will add some javascript and a tiny placeholder. - * This ensures that the browser can already determine the correct layout. - * When disabled, no tiny placeholder is generated. - */ - 'use_tiny_placeholders' => true, - - /* - * This class will generate the tiny placeholder used for progressive image loading. By default - * the media library will use a tiny blurred jpg image. - */ - 'tiny_placeholder_generator' => Blurred::class, - ], - - /* - * When enabling this option, a route will be registered that will enable - * the Media Library Pro Vue and React components to move uploaded files - * in a S3 bucket to their right place. - */ - 'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false), - - /* - * When converting Media instances to response the media library will add - * a `loading` attribute to the `img` tag. Here you can set the default - * value of that attribute. - * - * Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction. - * - * More info: https://css-tricks.com/native-lazy-loading/ - */ - 'default_loading_attribute_value' => null, - - /* - * You can specify a prefix for that is used for storing all media. - * If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory. - */ - 'prefix' => env('MEDIA_PREFIX', ''), - - /* - * When forcing lazy loading, media will be loaded even if you don't eager load media and you have - * disabled lazy loading globally in the service provider. - */ - 'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true), -]; diff --git a/config/permission.php b/config/permission.php deleted file mode 100644 index 85b88328..00000000 --- a/config/permission.php +++ /dev/null @@ -1,206 +0,0 @@ - [ - - /* - * When using the "HasPermissions" trait from this package, we need to know which - * Eloquent model should be used to retrieve your permissions. Of course, it - * is often just the "Permission" model but you may use whatever you like. - * - * The model you want to use as a Permission model needs to implement the - * `Spatie\Permission\Contracts\Permission` contract. - */ - - 'permission' => Permission::class, - - /* - * When using the "HasRoles" trait from this package, we need to know which - * Eloquent model should be used to retrieve your roles. Of course, it - * is often just the "Role" model but you may use whatever you like. - * - * The model you want to use as a Role model needs to implement the - * `Spatie\Permission\Contracts\Role` contract. - */ - - 'role' => Role::class, - - ], - - 'table_names' => [ - - /* - * When using the "HasRoles" trait from this package, we need to know which - * table should be used to retrieve your roles. We have chosen a basic - * default value but you may easily change it to any table you like. - */ - - 'roles' => 'roles', - - /* - * When using the "HasPermissions" trait from this package, we need to know which - * table should be used to retrieve your permissions. We have chosen a basic - * default value but you may easily change it to any table you like. - */ - - 'permissions' => 'permissions', - - /* - * When using the "HasPermissions" trait from this package, we need to know which - * table should be used to retrieve your models permissions. We have chosen a - * basic default value but you may easily change it to any table you like. - */ - - 'model_has_permissions' => 'model_has_permissions', - - /* - * When using the "HasRoles" trait from this package, we need to know which - * table should be used to retrieve your models roles. We have chosen a - * basic default value but you may easily change it to any table you like. - */ - - 'model_has_roles' => 'model_has_roles', - - /* - * When using the "HasRoles" trait from this package, we need to know which - * table should be used to retrieve your roles permissions. We have chosen a - * basic default value but you may easily change it to any table you like. - */ - - 'role_has_permissions' => 'role_has_permissions', - ], - - 'column_names' => [ - /* - * Change this if you want to name the related pivots other than defaults - */ - 'role_pivot_key' => null, // default 'role_id', - 'permission_pivot_key' => null, // default 'permission_id', - - /* - * Change this if you want to name the related model primary key other than - * `model_id`. - * - * For example, this would be nice if your primary keys are all UUIDs. In - * that case, name this `model_uuid`. - */ - - 'model_morph_key' => 'model_id', - - /* - * Change this if you want to use the teams feature and your related model's - * foreign key is other than `team_id`. - */ - - 'team_foreign_key' => 'team_id', - ], - - /* - * When set to true, the method for checking permissions will be registered on the gate. - * Set this to false if you want to implement custom logic for checking permissions. - */ - - 'register_permission_check_method' => true, - - /* - * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered - * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated - * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. - */ - 'register_octane_reset_listener' => false, - - /* - * Events will fire when a role or permission is assigned/unassigned: - * \Spatie\Permission\Events\RoleAttached - * \Spatie\Permission\Events\RoleDetached - * \Spatie\Permission\Events\PermissionAttached - * \Spatie\Permission\Events\PermissionDetached - * - * To enable, set to true, and then create listeners to watch these events. - */ - 'events_enabled' => false, - - /* - * Teams Feature. - * When set to true the package implements teams using the 'team_foreign_key'. - * If you want the migrations to register the 'team_foreign_key', you must - * set this to true before doing the migration. - * If you already did the migration then you must make a new migration to also - * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' - * (view the latest version of this package's migration file) - */ - - 'teams' => false, - - /* - * The class to use to resolve the permissions team id - */ - 'team_resolver' => DefaultTeamResolver::class, - - /* - * Passport Client Credentials Grant - * When set to true the package will use Passports Client to check permissions - */ - - 'use_passport_client_credentials' => false, - - /* - * When set to true, the required permission names are added to exception messages. - * This could be considered an information leak in some contexts, so the default - * setting is false here for optimum safety. - */ - - 'display_permission_in_exception' => false, - - /* - * When set to true, the required role names are added to exception messages. - * This could be considered an information leak in some contexts, so the default - * setting is false here for optimum safety. - */ - - 'display_role_in_exception' => false, - - /* - * By default wildcard permission lookups are disabled. - * See documentation to understand supported syntax. - */ - - 'enable_wildcard_permission' => false, - - /* - * The class to use for interpreting wildcard permissions. - * If you need to modify delimiters, override the class and specify its name here. - */ - // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, - - /* Cache-specific settings */ - - 'cache' => [ - - /* - * By default all permissions are cached for 24 hours to speed up performance. - * When permissions or roles are updated the cache is flushed automatically. - */ - - 'expiration_time' => DateInterval::createFromDateString('24 hours'), - - /* - * The cache key used to store all permissions. - */ - - 'key' => 'spatie.permission.cache', - - /* - * You may optionally indicate a specific cache driver to use for permission and - * role caching using any of the `store` drivers listed in the cache.php config - * file. Using 'default' here means to use the `default` set in cache.php. - */ - - 'store' => 'default', - ], -]; diff --git a/config/saucebase.php b/config/saucebase.php deleted file mode 100644 index 08b4f946..00000000 --- a/config/saucebase.php +++ /dev/null @@ -1,8 +0,0 @@ - [ - 'Basic Recipe' => 'stubs/saucebase/recipes/basic', - ], - 'ignore_files' => ['module.json'], -]; diff --git a/config/sluggable.php b/config/sluggable.php deleted file mode 100644 index ceeaab08..00000000 --- a/config/sluggable.php +++ /dev/null @@ -1,154 +0,0 @@ -name; - * - * Or it can be an array of fields, like ["name", "company"], which builds a slug from: - * - * $model->name . ' ' . $model->company; - * - * If you've defined custom getters in your model, you can use those too, - * since Eloquent will call them when you request a custom attribute. - * - * Defaults to null, which uses the toString() method on your model. - */ - - 'source' => null, - - /* - * The maximum length of a generated slug. Defaults to "null", which means - * no length restrictions are enforced. Set it to a positive integer if you - * want to make sure your slugs aren't too long. - */ - - 'maxLength' => null, - - /* - * If you are setting a maximum length on your slugs, you may not want the - * truncated string to split a word in half. The default setting of "true" - * will ensure this, e.g. with a maxLength of 12: - * - * "my source string" -> "my-source" - * - * Setting it to "false" will simply truncate the generated slug at the - * desired length, e.g.: - * - * "my source string" -> "my-source-st" - */ - - 'maxLengthKeepWords' => true, - - /* - * If left to "null", then use the cocur/slugify package to generate the slug - * (with the separator defined below). - * - * Set this to a closure that accepts two parameters (string and separator) - * to define a custom slugger. e.g.: - * - * 'method' => function( $string, $sep ) { - * return preg_replace('/[^a-z]+/i', $sep, $string); - * }, - * - * Otherwise, this will be treated as a callable to be used. e.g.: - * - * 'method' => array('Str','slug'), - */ - - 'method' => null, - - // Separator to use when generating slugs. Defaults to a hyphen. - - 'separator' => '-', - - /* - * Enforce uniqueness of slugs? Defaults to true. - * If a generated slug already exists, an incremental numeric - * value will be appended to the end until a unique slug is found. e.g.: - * - * my-slug - * my-slug-1 - * my-slug-2 - */ - - 'unique' => true, - - /* - * If you are enforcing unique slugs, the default is to add an - * incremental value to the end of the base slug. Alternatively, you - * can change this value to a closure that accepts three parameters: - * the base slug, the separator, and a Collection of the other - * "similar" slugs. The closure should return the new unique - * suffix to append to the slug. - */ - - 'uniqueSuffix' => null, - - /* - * What is the first suffix to add to a slug to make it unique? - * For the default method of adding incremental integers, we start - * counting at 2, so the list of slugs would be, e.g.: - * - * - my-post - * - my-post-2 - * - my-post-3 - */ - 'firstUniqueSuffix' => 2, - - /* - * Should we include the trashed items when generating a unique slug? - * This only applies if the softDelete property is set for the Eloquent model. - * If set to "false", then a new slug could duplicate one that exists on a trashed model. - * If set to "true", then uniqueness is enforced across trashed and existing models. - */ - - 'includeTrashed' => false, - - /* - * An array of slug names that can never be used for this model, - * e.g. to prevent collisions with existing routes or controller methods, etc.. - * Defaults to null (i.e. no reserved names). - * Can be a static array, e.g.: - * - * 'reserved' => array('add', 'delete'), - * - * or a closure that returns an array of reserved names. - * If using a closure, it will accept one parameter: the model itself, and should - * return an array of reserved names, or null. e.g. - * - * 'reserved' => function( Model $model) { - * return $model->some_method_that_returns_an_array(); - * } - * - * In the case of a slug that gets generated with one of these reserved names, - * we will do: - * - * $slug .= $separator + "1" - * - * and continue from there. - */ - - 'reserved' => null, - - /* - * Whether to update the slug value when a model is being - * re-saved (i.e. already exists). Defaults to false, which - * means slugs are not updated. - * - * Be careful! If you are using slugs to generate URLs, then - * updating your slug automatically might change your URLs which - * is probably not a good idea from an SEO point of view. - * Only set this to true if you understand the possible consequences. - */ - - 'onUpdate' => false, - - /* - * If the default slug engine of cocur/slugify is used, this array of - * configuration options will be used when instantiating the engine. - */ - 'slugEngineOptions' => [], -]; diff --git a/config/typescript-transformer.php b/config/typescript-transformer.php deleted file mode 100644 index 5b807692..00000000 --- a/config/typescript-transformer.php +++ /dev/null @@ -1,95 +0,0 @@ - [ - app_path(), - ], - - /* - * Collectors will search for classes in the `auto_discover_types` paths and choose the correct - * transformer to transform them. By default, we include a DefaultCollector which will search - * for @typescript annotated and #[TypeScript] attributed classes to transform. - */ - - 'collectors' => [ - DefaultCollector::class, - ], - - /* - * Transformers take PHP classes(e.g., enums) as an input and will output - * a TypeScript representation of the PHP class. - */ - - 'transformers' => [ - SpatieStateTransformer::class, - EnumTransformer::class, - DtoTransformer::class, - DataTypeScriptTransformer::class, - ], - - /* - * In your classes, you sometimes have types that should always be replaced - * by the same TypeScript representations. For example, you can replace a - * Datetime always with a string. You define these replacements here. - */ - - 'default_type_replacements' => [ - DateTime::class => 'string', - DateTimeImmutable::class => 'string', - CarbonInterface::class => 'string', - CarbonImmutable::class => 'string', - Carbon::class => 'string', - ], - - /* - * The package will write the generated TypeScript to this file. - */ - - 'output_file' => base_path('resources/js/types/generated.d.ts'), - - /* - * When the package is writing types to the output file, a writer is used to - * determine the format. By default, this is the `TypeDefinitionWriter`. - * But you can also use the `ModuleWriter` or implement your own. - */ - - 'writer' => TypeDefinitionWriter::class, - - /* - * The generated TypeScript file can be formatted. We ship a Prettier formatter - * out of the box: `PrettierFormatter` but you can also implement your own one. - * The generated TypeScript will not be formatted when no formatter was set. - */ - - 'formatter' => null, - - /* - * Enums can be transformed into types or native TypeScript enums, by default - * the package will transform them to types. - */ - - 'transform_to_native_enums' => false, - - /* - * By default, this package will convert PHP nullable properties to TypeScript - * types using a `null` type union. Setting `transform_null_to_optional` will - * make them optional instead. - */ - - 'transform_null_to_optional' => false, -]; diff --git a/config/ziggy.php b/config/ziggy.php deleted file mode 100644 index 86d3d0d0..00000000 --- a/config/ziggy.php +++ /dev/null @@ -1,4 +0,0 @@ - Date: Thu, 10 Sep 2026 20:15:03 +0100 Subject: [PATCH 5/8] chore: refactor imports to move configuration classes to core services and update routes --- app/Http/Controllers/IndexController.php | 2 +- app/Models/User.php | 2 +- app/Providers/Filament/AdminPanelProvider.php | 4 +- bootstrap/app.php | 6 +- composer.lock | 20 ++- config/app.php | 2 +- config/laravel-playwright.php | 22 --- .../laravel-translatable-string-exporter.php | 66 -------- ..._01_01_000003_create_permission_tables.php | 144 ------------------ .../0001_01_01_000004_create_media_table.php | 32 ---- ...001_01_01_000006_create_settings_table.php | 24 --- ...01_01_000008_add_locale_to_users_table.php | 26 ---- routes/web.php | 4 +- 13 files changed, 27 insertions(+), 327 deletions(-) delete mode 100644 config/laravel-playwright.php delete mode 100644 config/laravel-translatable-string-exporter.php delete mode 100644 database/migrations/0001_01_01_000003_create_permission_tables.php delete mode 100644 database/migrations/0001_01_01_000004_create_media_table.php delete mode 100644 database/migrations/0001_01_01_000006_create_settings_table.php delete mode 100644 database/migrations/0001_01_01_000008_add_locale_to_users_table.php diff --git a/app/Http/Controllers/IndexController.php b/app/Http/Controllers/IndexController.php index aca353a5..69334fb5 100644 --- a/app/Http/Controllers/IndexController.php +++ b/app/Http/Controllers/IndexController.php @@ -5,7 +5,7 @@ use Illuminate\Http\Response; use Inertia\Inertia; use Inertia\Response as InertiaResponse; -use Saucebase\Core\Frontend\FrontendConfig; +use Saucebase\Core\Services\FrontendConfig; class IndexController extends Controller { diff --git a/app/Models/User.php b/app/Models/User.php index bde1bf2e..2f6e5604 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Saucebase\Core\Localization\LocalizationSettings; +use Saucebase\Core\Settings\LocalizationSettings; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index c4df0b92..6dfd81cc 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -19,8 +19,8 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Openplain\FilamentShadcnTheme\Color; -use Saucebase\Core\Filament\Admin\GeneralSettings; -use Saucebase\Core\Filament\Admin\LocalizationSettings; +use Saucebase\Core\Filament\Admin\Pages\GeneralSettings; +use Saucebase\Core\Filament\Admin\Pages\LocalizationSettings; use Saucebase\Core\Filament\ModulesPlugin; class AdminPanelProvider extends PanelProvider diff --git a/bootstrap/app.php b/bootstrap/app.php index 6fd28804..5e03b7ea 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,9 +6,9 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Request; use Inertia\Inertia; -use Saucebase\Core\Frontend\HandleAppearance; -use Saucebase\Core\Inertia\HandleInertiaRequests; -use Saucebase\Core\Localization\HandleLocalization; +use Saucebase\Core\Http\Middleware\HandleAppearance; +use Saucebase\Core\Http\Middleware\HandleInertiaRequests; +use Saucebase\Core\Http\Middleware\HandleLocalization; use Spatie\Permission\Middleware\PermissionMiddleware; use Spatie\Permission\Middleware\RoleMiddleware; use Spatie\Permission\Middleware\RoleOrPermissionMiddleware; diff --git a/composer.lock b/composer.lock index 1b1e970e..607b2515 100644 --- a/composer.lock +++ b/composer.lock @@ -6843,7 +6843,7 @@ "dist": { "type": "path", "url": "../packages/core", - "reference": "e1ad091c29616927d33c49397880920c48937161" + "reference": "2d66a0fbe99b280678f5a7c01fc3ace065e1b70b" }, "require": { "cviebrock/eloquent-sluggable": "^13.0.1", @@ -6877,6 +6877,9 @@ "laravel": { "providers": [ "Saucebase\\Core\\CoreServiceProvider" + ], + "dont-discover": [ + "internachi/modular" ] } }, @@ -6885,7 +6888,7 @@ "Saucebase\\Core\\": "src/" }, "files": [ - "src/helpers.php" + "src/Helpers/helpers.php" ] }, "autoload-dev": { @@ -6893,6 +6896,17 @@ "Saucebase\\Core\\Tests\\": "tests/" } }, + "scripts": { + "test": [ + "phpunit" + ], + "lint": [ + "pint" + ], + "lint:test": [ + "pint --test" + ] + }, "license": [ "MIT" ], @@ -6906,7 +6920,7 @@ "email": "renanroble@gmail.com" } ], - "description": "The Saucebase core package — navigation, localization, module infrastructure, the settings modal, and the Inertia layer.", + "description": "The Saucebase core package", "keywords": [ "laravel", "saas", diff --git a/config/app.php b/config/app.php index 92732092..ce619050 100644 --- a/config/app.php +++ b/config/app.php @@ -98,7 +98,7 @@ | installed module, whether or not it is named here; a discovered locale with | no entry falls back to its own code. Which of them visitors can switch | between, and which one is the default, are set in the admin panel under - | Settings, and stored via Saucebase\Core\Localization\LocalizationSettings. + | Settings, and stored via Saucebase\Core\Settings\LocalizationSettings. | */ 'available_locales' => [ diff --git a/config/laravel-playwright.php b/config/laravel-playwright.php deleted file mode 100644 index de216c17..00000000 --- a/config/laravel-playwright.php +++ /dev/null @@ -1,22 +0,0 @@ - env('PLAYWRIGHT_PREFIX', 'playwright'), - - /** - * The environments in which the Playwright endpoints should be available. - */ - 'environments' => ['local', 'testing'], - - /** - * The secret key used to authenticate requests to the Playwright endpoints. - * You should set this to a random string in your .env file - * to prevent unauthorized access to the endpoints. - */ - 'secret' => env('PLAYWRIGHT_SECRET'), -]; diff --git a/config/laravel-translatable-string-exporter.php b/config/laravel-translatable-string-exporter.php deleted file mode 100644 index 43745f14..00000000 --- a/config/laravel-translatable-string-exporter.php +++ /dev/null @@ -1,66 +0,0 @@ - [ - 'app', - 'resources', - 'modules', - ], - - // Directories to exclude from search. - // - // Please note, these directories should be relative to the ones listed in 'directories'. - // For example, if you have 'resources' in 'directories', then to ignore the 'resources/ignored' directory, - // you need to add 'ignored' to the 'excluded-directories' list. - 'excluded-directories' => [ - ], - - // File Patterns to search for. - 'patterns' => [ - '*.php', - '*.js', - '*.vue', - '*.blade.php', - '*.ts', - ], - - // Indicates whether new lines are allowed in translations. - 'allow-newlines' => false, - - // Translation function names or a custom transform function. - // Example of a custom transform function: - // 'transform' => fn ($s) => \strtoupper(\str_replace(["-","_"], " ", $s)) - // If your function name contains $ escape it using \$ . - 'functions' => [ - '__', - 'trans_choice', - '_t', - '@lang', - '\$t', - 'trans', - ], - - // Indicates whether you need to sort the translations alphabetically - // by original strings (keys). - // It helps navigate a translation file and detect possible duplicates. - 'sort-keys' => true, - - // Indicates whether keys from the persistent-strings file should be also added - // to translation files automatically on export if they don't yet exist there. - 'add-persistent-strings-to-translations' => false, - - // Indicates whether it's necessary to exclude Laravel translation keys - // from the resulting language file if they have corresponding translations - // in the given language. - // This option allows correctly combine two translation approaches: - // Laravel translation keys (PHP) and translatable strings (JSON). - 'exclude-translation-keys' => true, - - // Indicates whether you need to put untranslated strings - // at the top of a translation file. - // The criterion of whether a string is untranslated is - // if its key and value are equivalent. - // If sorting is enabled, untranslated and translated strings are sorted separately. - 'put-untranslated-strings-at-the-top' => false, -]; diff --git a/database/migrations/0001_01_01_000003_create_permission_tables.php b/database/migrations/0001_01_01_000003_create_permission_tables.php deleted file mode 100644 index 6032b064..00000000 --- a/database/migrations/0001_01_01_000003_create_permission_tables.php +++ /dev/null @@ -1,144 +0,0 @@ -engine('InnoDB'); - $table->bigIncrements('id'); // permission id - $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) - $table->string('guard_name'); // For MyISAM use string('guard_name', 25); - $table->string('module')->nullable(); // For MyISAM use string('module', 225); - $table->timestamps(); - - $table->unique(['name', 'guard_name']); - }); - - Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { - // $table->engine('InnoDB'); - $table->bigIncrements('id'); // role id - if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing - $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); - $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); - } - $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) - $table->string('guard_name'); // For MyISAM use string('guard_name', 25); - $table->timestamps(); - if ($teams || config('permission.testing')) { - $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); - } else { - $table->unique(['name', 'guard_name']); - } - }); - - Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { - $table->unsignedBigInteger($pivotPermission); - - $table->string('model_type'); - $table->unsignedBigInteger($columnNames['model_morph_key']); - $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); - - $table->foreign($pivotPermission) - ->references('id') // permission id - ->on($tableNames['permissions']) - ->onDelete('cascade'); - if ($teams) { - $table->unsignedBigInteger($columnNames['team_foreign_key']); - $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); - - $table->primary( - [$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], - 'model_has_permissions_permission_model_type_primary' - ); - } else { - $table->primary( - [$pivotPermission, $columnNames['model_morph_key'], 'model_type'], - 'model_has_permissions_permission_model_type_primary' - ); - } - }); - - Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { - $table->unsignedBigInteger($pivotRole); - - $table->string('model_type'); - $table->unsignedBigInteger($columnNames['model_morph_key']); - $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); - - $table->foreign($pivotRole) - ->references('id') // role id - ->on($tableNames['roles']) - ->onDelete('cascade'); - if ($teams) { - $table->unsignedBigInteger($columnNames['team_foreign_key']); - $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); - - $table->primary( - [$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], - 'model_has_roles_role_model_type_primary' - ); - } else { - $table->primary( - [$pivotRole, $columnNames['model_morph_key'], 'model_type'], - 'model_has_roles_role_model_type_primary' - ); - } - }); - - Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { - $table->unsignedBigInteger($pivotPermission); - $table->unsignedBigInteger($pivotRole); - - $table->foreign($pivotPermission) - ->references('id') // permission id - ->on($tableNames['permissions']) - ->onDelete('cascade'); - - $table->foreign($pivotRole) - ->references('id') // role id - ->on($tableNames['roles']) - ->onDelete('cascade'); - - $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); - }); - - app('cache') - ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) - ->forget(config('permission.cache.key')); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - $tableNames = config('permission.table_names'); - - if (empty($tableNames)) { - throw new Exception('Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); - } - - Schema::drop($tableNames['role_has_permissions']); - Schema::drop($tableNames['model_has_roles']); - Schema::drop($tableNames['model_has_permissions']); - Schema::drop($tableNames['roles']); - Schema::drop($tableNames['permissions']); - } -}; diff --git a/database/migrations/0001_01_01_000004_create_media_table.php b/database/migrations/0001_01_01_000004_create_media_table.php deleted file mode 100644 index 47a4be98..00000000 --- a/database/migrations/0001_01_01_000004_create_media_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - - $table->morphs('model'); - $table->uuid()->nullable()->unique(); - $table->string('collection_name'); - $table->string('name'); - $table->string('file_name'); - $table->string('mime_type')->nullable(); - $table->string('disk'); - $table->string('conversions_disk')->nullable(); - $table->unsignedBigInteger('size'); - $table->json('manipulations'); - $table->json('custom_properties'); - $table->json('generated_conversions'); - $table->json('responsive_images'); - $table->unsignedInteger('order_column')->nullable()->index(); - - $table->nullableTimestamps(); - }); - } -}; diff --git a/database/migrations/0001_01_01_000006_create_settings_table.php b/database/migrations/0001_01_01_000006_create_settings_table.php deleted file mode 100644 index dd0be6f6..00000000 --- a/database/migrations/0001_01_01_000006_create_settings_table.php +++ /dev/null @@ -1,24 +0,0 @@ -id(); - - $table->string('group'); - $table->string('name'); - $table->boolean('locked')->default(false); - $table->json('payload'); - - $table->timestamps(); - - $table->unique(['group', 'name']); - }); - } -}; diff --git a/database/migrations/0001_01_01_000008_add_locale_to_users_table.php b/database/migrations/0001_01_01_000008_add_locale_to_users_table.php deleted file mode 100644 index 3e7bd1e0..00000000 --- a/database/migrations/0001_01_01_000008_add_locale_to_users_table.php +++ /dev/null @@ -1,26 +0,0 @@ -string('locale')->nullable()->after('avatar'); - }); - } - - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('locale'); - }); - } -}; diff --git a/routes/web.php b/routes/web.php index 075c3af2..497e5b91 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,8 +5,8 @@ use App\Http\Controllers\PrivacyController; use App\Http\Controllers\TermsController; use Illuminate\Support\Facades\Route; -use Saucebase\Core\Localization\LocalizationController; -use Saucebase\Core\Settings\SettingsController; +use Saucebase\Core\Http\Controllers\LocalizationController; +use Saucebase\Core\Http\Controllers\SettingsController; Route::get('/', IndexController::class)->name('index'); From c982b506210a5fef4e2394f890d20932e1a8bc47 Mon Sep 17 00:00:00 2001 From: roble Date: Thu, 10 Sep 2026 20:28:32 +0100 Subject: [PATCH 6/8] chore: remove obsolete language files and update PHPStan configuration for core migrations --- lang/en/pagination.php | 19 ---- lang/en/validation.php | 199 -------------------------------------- lang/pt_BR/validation.php | 3 + phpstan.neon | 8 ++ 4 files changed, 11 insertions(+), 218 deletions(-) delete mode 100644 lang/en/pagination.php delete mode 100644 lang/en/validation.php diff --git a/lang/en/pagination.php b/lang/en/pagination.php deleted file mode 100644 index d4814118..00000000 --- a/lang/en/pagination.php +++ /dev/null @@ -1,19 +0,0 @@ - '« Previous', - 'next' => 'Next »', - -]; diff --git a/lang/en/validation.php b/lang/en/validation.php deleted file mode 100644 index bd9cc110..00000000 --- a/lang/en/validation.php +++ /dev/null @@ -1,199 +0,0 @@ - 'The :attribute field must be accepted.', - 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', - 'active_url' => 'The :attribute field must be a valid URL.', - '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.', - '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.', - 'any_of' => 'The :attribute field is invalid.', - '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.', - ], - '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.', - 'contains' => 'The :attribute field is missing a required value.', - '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.', - '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_contain' => 'The :attribute field must not contain any of the following: :values.', - '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.', - '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.', - 'extensions' => 'The :attribute field must have one of the following extensions: :values.', - '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.', - ], - '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.', - ], - 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', - 'image' => 'The :attribute field must be an image.', - 'in' => 'The selected :attribute is invalid.', - 'in_array' => 'The :attribute field must exist in :other.', - 'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.', - '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.', - 'list' => 'The :attribute field must be a list.', - '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.', - ], - '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.', - ], - '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.', - ], - '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.', - ], - '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.', - ], - 'present' => 'The :attribute field must be present.', - 'present_if' => 'The :attribute field must be present when :other is :value.', - 'present_unless' => 'The :attribute field must be present unless :other is :value.', - 'present_with' => 'The :attribute field must be present when :values is present.', - 'present_with_all' => 'The :attribute field must be present when :values are present.', - 'prohibited' => 'The :attribute field is prohibited.', - 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', - 'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.', - 'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.', - '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_if_declined' => 'The :attribute field is required when :other is declined.', - '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.', - ], - '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.', - '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.', - 'ulid' => 'The :attribute field must be a valid ULID.', - 'uuid' => 'The :attribute field must be a valid UUID.', - - /* - |-------------------------------------------------------------------------- - | Custom Validation Language Lines - |-------------------------------------------------------------------------- - | - | Here you may specify custom validation messages for attributes using the - | convention "attribute.rule" to name the lines. This makes it quick to - | specify a specific custom language line for a given attribute rule. - | - */ - - 'custom' => [ - 'attribute-name' => [ - 'rule-name' => 'custom-message', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Custom Validation Attributes - |-------------------------------------------------------------------------- - | - | The following language lines are used to swap our attribute placeholder - | with something more reader friendly such as "E-Mail Address" instead - | of "email". This simply helps us make our message more expressive. - | - */ - - 'attributes' => [], - -]; diff --git a/lang/pt_BR/validation.php b/lang/pt_BR/validation.php index 17756f49..6bf02bdb 100644 --- a/lang/pt_BR/validation.php +++ b/lang/pt_BR/validation.php @@ -23,7 +23,9 @@ 'alpha_num' => 'O campo :attribute deve conter apenas letras e números.', 'any_of' => 'O campo :attribute é inválido.', 'array' => 'O campo :attribute deve ser um array.', + 'array_keys' => 'O campo :attribute deve conter apenas as seguintes chaves: :values.', 'ascii' => 'O campo :attribute deve conter apenas caracteres alfanuméricos e símbolos de um único byte.', + 'base64' => 'O campo :attribute deve ser uma string Base64 válida.', 'before' => 'O campo :attribute deve ser uma data anterior a :date.', 'before_or_equal' => 'O campo :attribute deve ser uma data anterior ou igual a :date.', 'between' => [ @@ -52,6 +54,7 @@ 'doesnt_end_with' => 'O campo :attribute não deve terminar com um dos seguintes valores: :values.', 'doesnt_start_with' => 'O campo :attribute não deve começar com um dos seguintes valores: :values.', 'email' => 'O campo :attribute deve ser um endereço de e-mail válido.', + 'encoding' => 'O campo :attribute deve estar codificado em :encoding.', 'ends_with' => 'O campo :attribute deve terminar com um dos seguintes valores: :values.', 'enum' => 'O :attribute selecionado é inválido.', 'exists' => 'O :attribute selecionado é inválido.', diff --git a/phpstan.neon b/phpstan.neon index 46b3b1dc..e427a65f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,6 +11,14 @@ parameters: - app/ - modules/ + # Larastan infers model properties by parsing migration files, and only looks in + # database/migrations by default. Core ships the migrations for its own features + # (permissions, media, settings, users.locale), so without this it reports those + # columns as undefined properties on App\Models\User. + databaseMigrationsPath: + - database/migrations + - vendor/saucebase/core/database/migrations + # Level 10 is the highest level level: 5 From bfef537d17172fb4a00f3812869b32754443c612 Mon Sep 17 00:00:00 2001 From: roble Date: Fri, 11 Sep 2026 09:12:24 +0100 Subject: [PATCH 7/8] chore: update GitHub Actions workflow to remove branch filter and add concurrency settings --- .github/workflows/test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93a6bb6d..0733a6b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,16 @@ name: Tests on: + # No `branches:` filter on purpose. That key matches the PR's *base* branch, so an + # allowlist silently skips CI for every epic branch (dev-v3, dev-improvements, ...) + # — and a skipped run shows as no checks at all, which reads like success. pull_request: - branches: [main] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read From 2b9c2e79badf6b265d9a1faf1a7ff4d4fdabf3b4 Mon Sep 17 00:00:00 2001 From: roble Date: Sat, 12 Sep 2026 12:30:19 +0100 Subject: [PATCH 8/8] fix: resolve core package from Packagist --- composer.json | 10 +-------- composer.lock | 59 +++++++++++++++++++++++---------------------------- 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/composer.json b/composer.json index a682df0e..2dc41ee0 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "php": "^8.4", "laravel/framework": "^13.30.1", "laravel/tinker": "^3.0.2", - "saucebase/core": "*" + "saucebase/core": "^1.0" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.7", @@ -126,14 +126,6 @@ "saas boilerplate" ], "repositories": [ - { - "type": "path", - "url": "../packages/*", - "canonical": false, - "options": { - "symlink": true - } - }, { "type": "path", "url": "modules/*", diff --git a/composer.lock b/composer.lock index 607b2515..47fbb65a 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": "c2270dda5abc5024f5143b8d7d7815d0", + "content-hash": "5cd8be5fab4a24a729e0acff12770f6c", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2924,16 +2924,16 @@ }, { "name": "inertiajs/inertia-laravel", - "version": "v3.3.3", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/inertiajs/inertia-laravel.git", - "reference": "42dccee77d7df6b2e152965434a89cafc35a2961" + "reference": "15fb5a7b2f984780ff968d9da3787aef6138326d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/42dccee77d7df6b2e152965434a89cafc35a2961", - "reference": "42dccee77d7df6b2e152965434a89cafc35a2961", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/15fb5a7b2f984780ff968d9da3787aef6138326d", + "reference": "15fb5a7b2f984780ff968d9da3787aef6138326d", "shasum": "" }, "require": { @@ -2990,9 +2990,9 @@ ], "support": { "issues": "https://github.com/inertiajs/inertia-laravel/issues", - "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.3" + "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.4" }, - "time": "2026-09-04T13:56:39+00:00" + "time": "2026-09-11T13:57:53+00:00" }, { "name": "inertiaui/modal", @@ -6839,11 +6839,17 @@ }, { "name": "saucebase/core", - "version": "0.1.0", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/saucebase-dev/core.git", + "reference": "62a51d57a3779a79898257244ff7b8f1b97a3b9a" + }, "dist": { - "type": "path", - "url": "../packages/core", - "reference": "2d66a0fbe99b280678f5a7c01fc3ace065e1b70b" + "type": "zip", + "url": "https://api.github.com/repos/saucebase-dev/core/zipball/62a51d57a3779a79898257244ff7b8f1b97a3b9a", + "reference": "62a51d57a3779a79898257244ff7b8f1b97a3b9a", + "shasum": "" }, "require": { "cviebrock/eloquent-sluggable": "^13.0.1", @@ -6868,6 +6874,7 @@ "tightenco/ziggy": "^2.6.4" }, "require-dev": { + "larastan/larastan": "^3.11", "laravel/pint": "^1.30.5", "orchestra/testbench": "^11.0", "phpunit/phpunit": "^12.5.34" @@ -6884,29 +6891,14 @@ } }, "autoload": { - "psr-4": { - "Saucebase\\Core\\": "src/" - }, "files": [ "src/Helpers/helpers.php" - ] - }, - "autoload-dev": { + ], "psr-4": { - "Saucebase\\Core\\Tests\\": "tests/" + "Saucebase\\Core\\": "src/" } }, - "scripts": { - "test": [ - "phpunit" - ], - "lint": [ - "pint" - ], - "lint:test": [ - "pint --test" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -6926,10 +6918,11 @@ "saas", "saucebase" ], - "transport-options": { - "symlink": true, - "relative": true - } + "support": { + "issues": "https://github.com/saucebase-dev/core/issues", + "source": "https://github.com/saucebase-dev/core/tree/v1.0.1" + }, + "time": "2026-09-11T18:45:42+00:00" }, { "name": "saucebase/module-installer",