diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index eafe5517cf..d05f6772c7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -2,6 +2,7 @@ name: PHP Tests on: push: + pull_request: schedule: - cron: '0 0 * * *' @@ -19,25 +20,11 @@ jobs: # run all combinations of the following, to make sure they're working together matrix: # os: [ubuntu-latest, macos-latest, windows-latest] - php: ['8.1', '8.2', '8.3', '8.4'] - laravel: [^10.0, ^11.0, ^12.0] - dbal: [^3.0] - phpunit: [10.*, 11.*] + php: ['8.2', '8.3', '8.4'] + laravel: [^12.0] + dbal: [^4.0] + phpunit: [11.*] dependency-version: [stable] # to add: lowest - exclude: - - laravel: "^11.0" - php: "8.1" - dbal: "^3.0" - - laravel: "^12.0" - php: "8.1" - dbal: "^3.0" - - laravel: "^12.0" - php: "8.2" - dbal: "^3.0" - - phpunit: "11.*" - laravel: "^10.0" - - phpunit: "10.*" - laravel: "^12.0" name: PHP ${{ matrix.php }}, Laravel ${{ matrix.laravel }}, PHPUnit ${{ matrix.phpunit }}, DBAL ${{ matrix.dbal }} --prefer-${{ matrix.dependency-version }} diff --git a/.gitignore b/.gitignore index 278ed279b9..19dafafeac 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ composer.lock .phpunit.result.cache src/public/packages/ /.phpunit.cache +coverage/ diff --git a/composer.json b/composer.json index a1d89005d6..b8a58cd6cc 100644 --- a/composer.json +++ b/composer.json @@ -35,16 +35,16 @@ } ], "require": { - "laravel/framework": "^10.0|^11.0|^12", - "backpack/basset": "^1.1.1|^1.3.2", + "laravel/framework": "^12", + "backpack/basset": "^2.0.0-beta", "creativeorange/gravatar": "^1.0", "prologue/alerts": "^1.0", - "doctrine/dbal": "^3.0|^4.0", + "doctrine/dbal": "^4.0", "guzzlehttp/guzzle": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.0|^9.0|^11.0", - "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpunit/phpunit": "^11.0", + "orchestra/testbench": "^10.0", "spatie/laravel-translatable": "^6.0" }, "autoload": { @@ -62,9 +62,12 @@ ] }, "scripts": { - "test": "vendor/bin/phpunit --testdox", + "test": [ + "@putenv XDEBUG_MODE=off", + "vendor/bin/phpunit" + ], "test-failing": "vendor/bin/phpunit --order-by=defects --stop-on-failure", - "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text" + "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage" }, "extra": { "branch-alias": { diff --git a/phpunit.xml b/phpunit.xml index 3c6e23bad0..d13d723465 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ ./src/app/Library/CrudPanel/Traits/ ./src/app/Library/Validation/ + ./src/app/Library/Uploaders/ ./src/app/Library/CrudPanel/ ./src/app/Models/Traits/ ./src/app/Library/Widget.php @@ -35,9 +36,11 @@ + + diff --git a/src/BackpackServiceProvider.php b/src/BackpackServiceProvider.php index c3119fc4a6..7966e4f329 100644 --- a/src/BackpackServiceProvider.php +++ b/src/BackpackServiceProvider.php @@ -5,13 +5,13 @@ use Backpack\Basset\Facades\Basset; use Backpack\CRUD\app\Http\Middleware\EnsureEmailVerification; use Backpack\CRUD\app\Http\Middleware\ThrottlePasswordRecovery; -use Backpack\CRUD\app\Library\CrudPanel\CrudPanel; use Backpack\CRUD\app\Library\Database\DatabaseSchema; use Backpack\CRUD\app\Library\Uploaders\Support\UploadersRepository; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Routing\Router; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\File; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Illuminate\View\Compilers\BladeCompiler; @@ -63,6 +63,26 @@ public function boot(Router $router) $this->sendUsageStats(); Basset::addViewPath(realpath(__DIR__.'/resources/views')); + + foreach (config('backpack.ui.styles', []) as $style) { + if (is_array($style)) { + foreach ($style as $file) { + Basset::map($file); + } + } else { + Basset::map($style); + } + } + + foreach (config('backpack.ui.scripts', []) as $script) { + if (is_array($script)) { + foreach ($script as $file) { + Basset::map($file); + } + } else { + Basset::map($script); + } + } } /** @@ -78,19 +98,29 @@ public function register() $this->loadViewsWithFallbacks('crud'); $this->loadViewsWithFallbacks('ui', 'backpack.ui'); $this->loadViewNamespace('widgets', 'backpack.ui::widgets'); + ViewNamespaces::addFor('widgets', 'crud::widgets'); + $this->loadViewComponents(); + $this->registerDynamicBladeComponents(); $this->registerBackpackErrorViews(); - // Bind the CrudPanel object to Laravel's service container - $this->app->scoped('crud', function ($app) { - return new CrudPanel(); + $this->app->bind('crud', function ($app) { + return CrudManager::identifyCrudPanel(); + }); + + $this->app->scoped('CrudManager', function ($app) { + return new CrudPanelManager(); }); $this->app->scoped('DatabaseSchema', function ($app) { return new DatabaseSchema(); }); + $this->app->scoped('BackpackLifecycleHooks', function ($app) { + return new app\Library\CrudPanel\Hooks\LifecycleHooks(); + }); + $this->app->singleton('BackpackViewNamespaces', function ($app) { return new ViewNamespaces(); }); @@ -177,7 +207,7 @@ public function publishFiles() /** * Define the routes for the application. * - * @param \Illuminate\Routing\Router $router + * @param Router $router * @return void */ public function setupRoutes(Router $router) @@ -196,7 +226,7 @@ public function setupRoutes(Router $router) /** * Load custom routes file. * - * @param \Illuminate\Routing\Router $router + * @param Router $router * @return void */ public function setupCustomRoutes(Router $router) @@ -314,6 +344,38 @@ public function loadViewComponents() }); } + /** + * Register dynamic Blade components from the Components directory. + * + * Any Blade component classes that are in that directory will be registered + * as dynamic components with the 'bp-{component-name}' prefix. + */ + private function registerDynamicBladeComponents() + { + $path = __DIR__.'/app/View/Components'; + $namespace = 'Backpack\\CRUD\\app\\View\\Components'; + + if (! is_dir($path)) { + return; + } + + foreach (File::allFiles($path) as $file) { + $relativePath = str_replace( + ['/', '.php'], + ['\\', ''], + Str::after($file->getRealPath(), realpath($path).DIRECTORY_SEPARATOR) + ); + + $class = $namespace.'\\'.$relativePath; + + // Check if the class exists and is a subclass of Illuminate\View\Component + // This ensures that only valid Blade components are registered. + if (class_exists($class) && is_subclass_of($class, \Illuminate\View\Component::class)) { + Blade::component('bp-'.Str::kebab(class_basename($class)), $class); + } + } + } + /** * Load the Backpack helper methods, for convenience. */ @@ -329,7 +391,7 @@ public function loadHelpers() */ public function provides() { - return ['crud', 'widgets', 'BackpackViewNamespaces', 'DatabaseSchema', 'UploadersRepository']; + return ['widgets', 'BackpackViewNamespaces', 'DatabaseSchema', 'UploadersRepository', 'CrudManager']; } private function registerBackpackErrorViews() diff --git a/src/CrudManager.php b/src/CrudManager.php new file mode 100644 index 0000000000..6f9ee1f885 --- /dev/null +++ b/src/CrudManager.php @@ -0,0 +1,16 @@ + Registry of CrudPanel instances indexed by controller class name */ + private array $cruds = []; + + /** @var array> Tracks which operations have been initialized for each controller */ + private array $initializedOperations = []; + + /** @var string|null The currently active controller class name */ + private ?string $currentlyActiveCrudController = null; + + /** + * Get or create a CrudPanel instance for the given controller. + */ + public function getCrudPanel(CrudControllerContract|string $controller): CrudPanel + { + $controllerClass = is_string($controller) ? $controller : get_class($controller); + + if (isset($this->cruds[$controllerClass])) { + return $this->cruds[$controllerClass]; + } + + $instance = new CrudPanel(); + + $this->cruds[$controllerClass] = $instance; + + return $this->cruds[$controllerClass]; + } + + /** + * Setup and initialize a CrudPanel for the given controller and operation. + * + * @param string $controller The controller class name + * @param string|null $operation The operation to set (defaults to 'list') + * @return CrudPanel The initialized CrudPanel instance + */ + public function setupCrudPanel(string $controller, ?string $operation = null): CrudPanel + { + $controller = $this->getActiveController() ?? $controller; + + $controller = is_string($controller) ? app($controller) : $controller; + + $crud = $this->getCrudPanel($controller); + + // Use provided operation or default to 'list' + $operation = $operation ?? 'list'; + $crud->setOperation($operation); + + $primaryControllerRequest = $this->cruds[array_key_first($this->cruds)]->getRequest(); + if (! $crud->isInitialized() || ! $this->isOperationInitialized($controller::class, $operation)) { + self::setActiveController($controller::class); + $crud->initialized = false; + self::setActiveController($controller::class); + $controller->initializeCrudPanel($primaryControllerRequest, $crud); + self::unsetActiveController(); + $crud = $this->cruds[$controller::class]; + + return $this->cruds[$controller::class]; + } + + return $this->cruds[$controller::class]; + } + + /** + * Check if a specific operation has been initialized for a controller. + */ + public function isOperationInitialized(string $controller, string $operation): bool + { + return in_array($operation, $this->getInitializedOperations($controller), true); + } + + /** + * Record that an operation has been initialized for a controller. + * + * @param string $controller The controller class name + * @param string $operation The operation name (e.g., 'list', 'create', 'update') + */ + public function storeInitializedOperation(string $controller, ?string $operation): void + { + if (! $operation) { + return; + } + $this->initializedOperations[$controller][] = $operation; + } + + /** + * Get the list of operations that have been initialized for a controller. + * + * @param string $controller The controller class name + * @return array Array of initialized operation names + */ + public function getInitializedOperations(string $controller): array + { + return $this->initializedOperations[$controller] ?? []; + } + + /** + * Store a CrudPanel instance for a specific controller. + */ + public function storeCrudPanel(string $controller, CrudPanel $crud): void + { + $this->cruds[$controller] = $crud; + } + + /** + * Check if a CrudPanel exists for the given controller. + */ + public function hasCrudPanel(string $controller): bool + { + return isset($this->cruds[$controller]); + } + + /** + * Get the active CrudPanel for a controller, with fallback logic. + * + * @param string $controller The controller class name + * @return CrudPanel The CrudPanel instance, creating one if necessary + */ + public function getActiveCrudPanel(string $controller): CrudPanel + { + if (! isset($this->cruds[$controller])) { + return $this->getCrudPanel($this->getActiveController() ?? $this->getParentController() ?? $controller); + } + + return $this->cruds[$controller]; + } + + /** + * Get the parent (first registered) controller class name. + * + * @return string|null The parent controller class name or null if none exists + */ + public function getParentController(): ?string + { + if (! empty($this->cruds)) { + return array_key_first($this->cruds); + } + + return $this->getActiveController(); + } + + /** + * Set the currently active controller and clear the CRUD facade cache. + * + * @param string $controller The controller class name to set as active + */ + public function setActiveController(string $controller): void + { + Facade::clearResolvedInstance('crud'); + $this->currentlyActiveCrudController = $controller; + } + + /** + * Get the currently active controller class name. + * + * @return string|null The active controller class name or null if none is set + */ + public function getActiveController(): ?string + { + return $this->currentlyActiveCrudController; + } + + /** + * Clear the currently active controller. + */ + public function unsetActiveController(): void + { + $this->currentlyActiveCrudController = null; + } + + /** + * Intelligently identify and return the appropriate CrudPanel based on context. + * + * This method uses multiple strategies to find the correct CrudPanel: + * 1. Use the currently active controller if set + * 2. Analyze the call stack to find a CRUD controller in the backtrace + * 3. Return the first available CrudPanel if any exist + * 4. Create a default CrudPanel as a last resort + * + * @return CrudPanel The identified or created CrudPanel instance + */ + public function identifyCrudPanel(): CrudPanel + { + if ($this->getActiveController()) { + return $this->getCrudPanel($this->getActiveController()); + } + + // Prioritize explicit controller context + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $controller = null; + + foreach ($trace as $step) { + if (isset($step['class']) && + is_a($step['class'], CrudControllerContract::class, true) && + ! is_a($step['class'], CrudController::class, true)) { + $controller = (string) $step['class']; + break; + } + } + + if ($controller) { + $crudPanel = $this->getActiveCrudPanel($controller); + + return $crudPanel; + } + + $cruds = $this->getCrudPanels(); + + if (! empty($cruds)) { + $crudPanel = end($cruds); + + return $crudPanel; + } + + $this->cruds[CrudController::class] = new CrudPanel(); + + return $this->cruds[CrudController::class]; + } + + /** + * Get all registered CrudPanel instances. + * + * @return array Array of CrudPanel instances indexed by controller class name + */ + public function getCrudPanels(): array + { + return $this->cruds; + } +} diff --git a/src/ThemeServiceProvider.php b/src/ThemeServiceProvider.php index 107580bb1e..2cdd25e706 100644 --- a/src/ThemeServiceProvider.php +++ b/src/ThemeServiceProvider.php @@ -14,7 +14,7 @@ class ThemeServiceProvider extends ServiceProvider protected string $packageName = 'theme-name'; protected array $commands = []; protected bool $theme = true; - protected null|string $componentsNamespace = null; + protected ?string $componentsNamespace = null; /** * ------------------------- @@ -92,6 +92,26 @@ public function loadViews() // Add basset view path Basset::addViewPath($this->packageViewsPath()); + + foreach (config($this->vendorNameDotPackageName().'.styles', []) as $path) { + if (is_array($path)) { + foreach ($path as $style) { + Basset::map($style, $style); + } + } else { + Basset::map($path, $path); + } + } + + foreach (config($this->vendorNameDotPackageName().'.scripts', []) as $path) { + if (is_array($path)) { + foreach ($path as $script) { + Basset::map($script, $script); + } + } else { + Basset::map($path, $path); + } + } } /** diff --git a/src/ViewNamespaces.php b/src/ViewNamespaces.php index fb28ed8815..e3283175a2 100644 --- a/src/ViewNamespaces.php +++ b/src/ViewNamespaces.php @@ -89,4 +89,25 @@ public static function getViewPathsFor(string $domain, string $viewName) return $item.'.'.$viewName; }, self::getFor($domain)); } + + /** + * Get view paths for a domain and view name with fallback support. + * This is useful for @includeFirst() calls that need a guaranteed fallback. + * + * @param string $domain (eg. fields, filters, buttons, columns) + * @param string $viewName (eg. text, select, checkbox) + * @param string|null $fallbackViewPath (eg. 'crud::columns.text') + * @return array + */ + public static function getViewPathsWithFallbackFor(string $domain, string $viewName, ?string $fallbackViewPath = null) + { + $paths = self::getViewPathsFor($domain, $viewName); + + // Add fallback if provided and not already in the list + if ($fallbackViewPath && ! in_array($fallbackViewPath, $paths)) { + $paths[] = $fallbackViewPath; + } + + return $paths; + } } diff --git a/src/app/Console/Commands/Install.php b/src/app/Console/Commands/Install.php index cb8f8abdfc..cdd6199028 100644 --- a/src/app/Console/Commands/Install.php +++ b/src/app/Console/Commands/Install.php @@ -85,7 +85,7 @@ public function handle() $this->progressBlock('Installing Generators'); if (! file_exists('vendor/backpack/generators/composer.json')) { // only do this if Generators aren't already required - $process = new Process(['composer', 'require', '--dev', 'backpack/generators']); + $process = new Process(['composer', 'require', '--dev', 'backpack/generators:dev-next']); $process->setTimeout(300); $process->run(); } diff --git a/src/app/Http/Controllers/Auth/VerifyEmailController.php b/src/app/Http/Controllers/Auth/VerifyEmailController.php index 6beb5bc451..20ceb735fc 100644 --- a/src/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/src/app/Http/Controllers/Auth/VerifyEmailController.php @@ -11,7 +11,7 @@ class VerifyEmailController extends Controller { - public null|string $redirectTo = null; + public ?string $redirectTo = null; /** * Create a new controller instance. diff --git a/src/app/Http/Controllers/Contracts/CrudControllerContract.php b/src/app/Http/Controllers/Contracts/CrudControllerContract.php new file mode 100644 index 0000000000..8789d57277 --- /dev/null +++ b/src/app/Http/Controllers/Contracts/CrudControllerContract.php @@ -0,0 +1,7 @@ +crud) { - return; - } - // --------------------------- // Create the CrudPanel object // --------------------------- @@ -36,18 +35,51 @@ public function __construct() // It's done inside a middleware closure in order to have // the complete request inside the CrudPanel object. $this->middleware(function ($request, $next) { - $this->crud = app('crud'); + if (! CrudManager::hasCrudPanel(get_class($this))) { + $this->initializeCrudPanel($request); - $this->crud->setRequest($request); + return $next($request); + } - $this->setupDefaults(); - $this->setup(); - $this->setupConfigurationForCurrentOperation(); + $this->setupCrudController(); + + CrudManager::getCrudPanel($this)->setRequest($request); return $next($request); }); } + public function initializeCrudPanel($request, $crudPanel = null): void + { + $crudPanel ??= CrudManager::getCrudPanel($this); + + $crudPanel = $crudPanel->initialize(get_class($this), $request); + + CrudManager::storeInitializedOperation( + get_class($this), + $crudPanel->getCurrentOperation() + ); + + if (! $crudPanel->isInitialized()) { + $crudPanel->initialized = true; + + $this->setupCrudController($crudPanel->getCurrentOperation()); + } + + CrudManager::storeCrudPanel(get_class($this), $crudPanel); + } + + private function setupCrudController($operation = null) + { + LifecycleHook::trigger('crud:before_setup_defaults', [$this]); + $this->setupDefaults(); + LifecycleHook::trigger('crud:after_setup_defaults', [$this]); + LifecycleHook::trigger('crud:before_setup', [$this]); + $this->setup(); + LifecycleHook::trigger('crud:after_setup', [$this]); + $this->setupConfigurationForCurrentOperation($operation); + } + /** * Allow developers to set their configuration options for a CrudPanel. */ @@ -83,7 +115,6 @@ public function setupRoutes($segment, $routeName, $controller) protected function setupDefaults() { preg_match_all('/(?<=^|;)setup([^;]+?)Defaults(;|$)/', implode(';', get_class_methods($this)), $matches); - if (count($matches[1])) { foreach ($matches[1] as $methodName) { $this->{'setup'.$methodName.'Defaults'}(); @@ -97,32 +128,43 @@ protected function setupDefaults() * Allow developers to insert default settings by creating a method * that looks like setupOperationNameOperation (aka setupXxxOperation). */ - protected function setupConfigurationForCurrentOperation() + protected function setupConfigurationForCurrentOperation(?string $operation = null) { - $operationName = $this->crud->getCurrentOperation(); + $operationName = $operation ?? $this->crud->getCurrentOperation(); if (! $operationName) { return; } - $setupClassName = 'setup'.Str::studly($operationName).'Operation'; /* * FIRST, run all Operation Closures for this operation. * - * It's preferred for this to closures first, because + * It's preferred for this to run closures first, because * (1) setup() is usually higher in a controller than any other method, so it's more intuitive, * since the first thing you write is the first thing that is being run; * (2) operations use operation closures themselves, inside their setupXxxDefaults(), and * you'd like the defaults to be applied before anything you write. That way, anything you * write is done after the default, so you can remove default settings, etc; */ - $this->crud->applyConfigurationFromSettings($operationName); + LifecycleHook::trigger($operationName.':before_setup', [$this]); + $this->crud->applyConfigurationFromSettings($operationName); /* * THEN, run the corresponding setupXxxOperation if it exists. */ if (method_exists($this, $setupClassName)) { $this->{$setupClassName}(); } + + LifecycleHook::trigger($operationName.':after_setup', [$this]); + } + + public function __get($name) + { + if ($name === 'crud') { + return CrudManager::getActiveCrudPanel(get_class($this)); + } + + return $this->{$name}; } } diff --git a/src/app/Http/Controllers/Operations/Concerns/HasForm.php b/src/app/Http/Controllers/Operations/Concerns/HasForm.php index 1e38e83dba..10a202a963 100644 --- a/src/app/Http/Controllers/Operations/Concerns/HasForm.php +++ b/src/app/Http/Controllers/Operations/Concerns/HasForm.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Http\Controllers\Operations\Concerns; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; @@ -38,8 +39,7 @@ protected function formDefaults(string $operationName, string $buttonStack = 'li // Access $this->crud->allowAccess($operationName); - // Config - $this->crud->operation($operationName, function () use ($operationName) { + LifecycleHook::hookInto($operationName.':before_setup', function () use ($operationName) { // if the backpack.operations.{operationName} config exists, use that one // otherwise, use the generic backpack.operations.form config if (config()->has('backpack.operations.'.$operationName)) { @@ -61,8 +61,7 @@ protected function formDefaults(string $operationName, string $buttonStack = 'li ]); }); - // Default Button - $this->crud->operation(['list', 'show'], function () use ($operationName, $buttonStack, $buttonMeta) { + LifecycleHook::hookInto(['list:before_setup', 'show:before_setup'], function () use ($operationName, $buttonStack, $buttonMeta) { $this->crud->button($operationName)->view('crud::buttons.quick')->stack($buttonStack)->meta($buttonMeta); }); } diff --git a/src/app/Http/Controllers/Operations/CreateOperation.php b/src/app/Http/Controllers/Operations/CreateOperation.php index 58ca954733..e6282ff68f 100644 --- a/src/app/Http/Controllers/Operations/CreateOperation.php +++ b/src/app/Http/Controllers/Operations/CreateOperation.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Http\Controllers\Operations; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; use Illuminate\Support\Facades\Route; trait CreateOperation @@ -35,12 +36,11 @@ protected function setupCreateDefaults() { $this->crud->allowAccess('create'); - $this->crud->operation('create', function () { - $this->crud->loadDefaultOperationSettingsFromConfig(); + LifecycleHook::hookInto('create:before_setup', function () { $this->crud->setupDefaultSaveActions(); }); - $this->crud->operation('list', function () { + LifecycleHook::hookInto('list:before_setup', function () { $this->crud->addButton('top', 'create', 'view', 'crud::buttons.create'); }); } diff --git a/src/app/Http/Controllers/Operations/DeleteOperation.php b/src/app/Http/Controllers/Operations/DeleteOperation.php index 81a80199fa..7de2afb39f 100644 --- a/src/app/Http/Controllers/Operations/DeleteOperation.php +++ b/src/app/Http/Controllers/Operations/DeleteOperation.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Http\Controllers\Operations; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; use Illuminate\Support\Facades\Route; trait DeleteOperation @@ -29,13 +30,25 @@ protected function setupDeleteDefaults() { $this->crud->allowAccess('delete'); - $this->crud->operation('delete', function () { + LifecycleHook::hookInto('delete:before_setup', function () { $this->crud->loadDefaultOperationSettingsFromConfig(); }); - $this->crud->operation(['list', 'show'], function () { + LifecycleHook::hookInto(['list:before_setup', 'show:before_setup'], function () { $this->crud->addButton('line', 'delete', 'view', 'crud::buttons.delete', 'end'); }); + + // setup the default redirect to where user will be redirected after delete + // if user has access to list, redirect to list, otherwise redirect to previous page + LifecycleHook::hookInto('show:before_setup', function () { + $this->crud->setOperationSetting('deleteButtonRedirect', function () { + if ($this->crud->hasAccess('list')) { + return url($this->crud->route); + } + + return url()->previous(); + }); + }); } /** diff --git a/src/app/Http/Controllers/Operations/ListOperation.php b/src/app/Http/Controllers/Operations/ListOperation.php index ed20569299..f04a542568 100644 --- a/src/app/Http/Controllers/Operations/ListOperation.php +++ b/src/app/Http/Controllers/Operations/ListOperation.php @@ -2,6 +2,8 @@ namespace Backpack\CRUD\app\Http\Controllers\Operations; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; +use Backpack\CRUD\app\Library\Support\DatatableCache; use Illuminate\Support\Facades\Route; trait ListOperation @@ -43,8 +45,9 @@ protected function setupListDefaults() { $this->crud->allowAccess('list'); - $this->crud->operation('list', function () { + LifecycleHook::hookInto('list:before_setup', function () { $this->crud->loadDefaultOperationSettingsFromConfig(); + $this->crud->setOperationSetting('datatablesUrl', $this->crud->getRoute()); }); } @@ -59,6 +62,7 @@ public function index() $this->data['crud'] = $this->crud; $this->data['title'] = $this->crud->getTitle() ?? mb_ucfirst($this->crud->entity_name_plural); + $this->data['controller'] = get_class($this); // load the view from /resources/views/vendor/backpack/crud/ if it exists, otherwise load the one in the package return view($this->crud->getListView(), $this->data); @@ -73,6 +77,10 @@ public function search() { $this->crud->hasAccessOrFail('list'); + // If there's a config closure in the cache for this CRUD, run that configuration closure. + // This is done in order to allow the developer to configure the datatable component. + DatatableCache::applyFromRequest($this->crud); + $this->crud->applyUnappliedFilters(); $start = (int) request()->input('start'); @@ -103,10 +111,10 @@ public function search() $this->crud->applyDatatableOrder(); $entries = $this->crud->getEntries(); - + $requestTotalEntryCount = request()->get('totalEntryCount') ? (int) request()->get('totalEntryCount') : null; // if show entry count is disabled we use the "simplePagination" technique to move between pages. if ($this->crud->getOperationSetting('showEntryCount')) { - $totalEntryCount = (int) (request()->get('totalEntryCount') ?: $this->crud->getTotalQueryCount()); + $totalEntryCount = (int) ($requestTotalEntryCount ?: $this->crud->getTotalQueryCount()); $filteredEntryCount = $this->crud->getFilteredQueryCount() ?? $totalEntryCount; } else { $totalEntryCount = $length; diff --git a/src/app/Http/Controllers/Operations/ReorderOperation.php b/src/app/Http/Controllers/Operations/ReorderOperation.php index 0fe547f78a..90fcee0efd 100644 --- a/src/app/Http/Controllers/Operations/ReorderOperation.php +++ b/src/app/Http/Controllers/Operations/ReorderOperation.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Http\Controllers\Operations; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; use Illuminate\Support\Facades\Route; trait ReorderOperation @@ -36,7 +37,7 @@ protected function setupReorderDefaults() $this->crud->set('reorder.enabled', true); $this->crud->allowAccess('reorder'); - $this->crud->operation('reorder', function () { + LifecycleHook::hookInto('reorder:before_setup', function () { $this->crud->loadDefaultOperationSettingsFromConfig(); $this->crud->setOperationSetting('reorderColumnNames', [ 'parent_id' => 'parent_id', @@ -46,7 +47,7 @@ protected function setupReorderDefaults() ]); }); - $this->crud->operation('list', function () { + LifecycleHook::hookInto('list:before_setup', function () { $this->crud->addButton('top', 'reorder', 'view', 'crud::buttons.reorder'); }); } diff --git a/src/app/Http/Controllers/Operations/ShowOperation.php b/src/app/Http/Controllers/Operations/ShowOperation.php index 4d44978687..31977b763b 100644 --- a/src/app/Http/Controllers/Operations/ShowOperation.php +++ b/src/app/Http/Controllers/Operations/ShowOperation.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Http\Controllers\Operations; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; use Illuminate\Support\Facades\Route; trait ShowOperation @@ -30,7 +31,7 @@ protected function setupShowDefaults() $this->crud->allowAccess('show'); $this->crud->setOperationSetting('setFromDb', true); - $this->crud->operation('show', function () { + LifecycleHook::hookInto('show:before_setup', function () { $this->crud->loadDefaultOperationSettingsFromConfig(); if (! method_exists($this, 'setupShowOperation')) { @@ -38,11 +39,11 @@ protected function setupShowDefaults() } }); - $this->crud->operation('list', function () { + LifecycleHook::hookInto(['list:before_setup'], function () { $this->crud->addButton('line', 'show', 'view', 'crud::buttons.show', 'beginning'); }); - $this->crud->operation(['create', 'update'], function () { + LifecycleHook::hookInto(['create:before_setup', 'update:before_setup'], function () { $this->crud->addSaveAction([ 'name' => 'save_and_preview', 'visible' => function ($crud) { diff --git a/src/app/Http/Controllers/Operations/UpdateOperation.php b/src/app/Http/Controllers/Operations/UpdateOperation.php index f3c622a9c5..f0c656092c 100644 --- a/src/app/Http/Controllers/Operations/UpdateOperation.php +++ b/src/app/Http/Controllers/Operations/UpdateOperation.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Http\Controllers\Operations; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; use Illuminate\Support\Facades\Route; trait UpdateOperation @@ -35,7 +36,7 @@ protected function setupUpdateDefaults() { $this->crud->allowAccess('update'); - $this->crud->operation('update', function () { + LifecycleHook::hookInto('update:before_setup', function () { $this->crud->loadDefaultOperationSettingsFromConfig(); if ($this->crud->getModel()->translationEnabled()) { @@ -49,7 +50,7 @@ protected function setupUpdateDefaults() $this->crud->setupDefaultSaveActions(); }); - $this->crud->operation(['list', 'show'], function () { + LifecycleHook::hookInto(['list:before_setup', 'show:before_setup'], function () { $this->crud->addButton('line', 'update', 'view', 'crud::buttons.update', 'end'); }); } diff --git a/src/app/Library/CrudPanel/CrudButton.php b/src/app/Library/CrudPanel/CrudButton.php index e34bd0ca09..e3f5d8e3ba 100644 --- a/src/app/Library/CrudPanel/CrudButton.php +++ b/src/app/Library/CrudPanel/CrudButton.php @@ -295,12 +295,14 @@ public function section($stack) * The HTML itself of the button. * * @param object|null $entry The eloquent Model for the current entry or null if no current entry. + * @param \Backpack\CRUD\app\Library\CrudPanel\CrudPanel|null $crud The CRUD panel instance. + * @param string|null $crudTableId The ID of the DataTable for multi-table support. * @return \Illuminate\Contracts\View\View */ - public function getHtml($entry = null) + public function getHtml($entry = null, ?CrudPanel $crud = null, ?string $crudTableId = null) { $button = $this; - $crud = $this->crud(); + $crud = $crud ?? $this->crud(); if ($this->type == 'model_function') { if (is_null($entry)) { @@ -311,7 +313,7 @@ public function getHtml($entry = null) } if ($this->type == 'view') { - return view($button->getFinalViewPath(), compact('button', 'crud', 'entry')); + return view($button->getFinalViewPath(), compact('button', 'crud', 'entry', 'crudTableId')); } abort(500, 'Unknown button type', ['developer-error-exception']); @@ -434,7 +436,7 @@ public function collection() /** * Access the global CrudPanel object. * - * @return \Backpack\CRUD\app\Library\CrudPanel\CrudPanel + * @return CrudPanel */ public function crud() { diff --git a/src/app/Library/CrudPanel/CrudColumn.php b/src/app/Library/CrudPanel/CrudColumn.php index be2a89d39b..95e891bf1c 100644 --- a/src/app/Library/CrudPanel/CrudColumn.php +++ b/src/app/Library/CrudPanel/CrudColumn.php @@ -289,7 +289,6 @@ private function setAllAttributeValues($array) private function save() { $key = $this->attributes['key'] ?? $this->attributes['name']; - if ($this->crud()->hasColumnWhere('key', $key)) { $this->crud()->setColumnDetails($key, $this->attributes); } else { diff --git a/src/app/Library/CrudPanel/CrudField.php b/src/app/Library/CrudPanel/CrudField.php index 2877b79f7e..69269e2dc8 100644 --- a/src/app/Library/CrudPanel/CrudField.php +++ b/src/app/Library/CrudPanel/CrudField.php @@ -77,7 +77,7 @@ public function __construct($nameOrDefinitionArray) public function crud() { - return app()->make('crud'); + return app('crud'); } /** diff --git a/src/app/Library/CrudPanel/CrudPanel.php b/src/app/Library/CrudPanel/CrudPanel.php index 6b8d030c95..e00d3fd2ef 100644 --- a/src/app/Library/CrudPanel/CrudPanel.php +++ b/src/app/Library/CrudPanel/CrudPanel.php @@ -35,7 +35,9 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Http\Request; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Route; class CrudPanel { @@ -64,31 +66,45 @@ class CrudPanel protected $request; + public bool $initialized = false; + + public $controller; + // The following methods are used in CrudController or your EntityCrudController to manipulate the variables above. public function __construct() { - $this->setRequest(); + } - if ($this->getCurrentOperation()) { - $this->setOperation($this->getCurrentOperation()); - } + public function isInitialized() + { + return $this->initialized; + } + + public function initialize(string $controller, $request): self + { + $this->setRequest($request); + $this->setController($controller); + + return $this; } /** * Set the request instance for this CRUD. * - * @param \Illuminate\Http\Request $request + * @param Request $request */ - public function setRequest($request = null) + public function setRequest($request = null): self { $this->request = $request ?? \Request::instance(); + + return $this; } /** * Get the request instance for this CRUD. * - * @return \Illuminate\Http\Request + * @return Request */ public function getRequest() { @@ -142,6 +158,11 @@ private function getSchema() return $this->getModel()->getConnection()->getSchemaBuilder(); } + public function setController(string $crudController) + { + $this->controller = $crudController; + } + /** * Check if the database connection driver is using mongodb. * diff --git a/src/app/Library/CrudPanel/CrudRouter.php b/src/app/Library/CrudPanel/CrudRouter.php index 0c1c1f464c..fd52ee5cd8 100644 --- a/src/app/Library/CrudPanel/CrudRouter.php +++ b/src/app/Library/CrudPanel/CrudRouter.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Library\CrudPanel; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; use Illuminate\Support\Facades\App; use ReflectionClass; @@ -18,7 +19,9 @@ public static function setupControllerRoutes(string $name, string $routeName, st if (empty($setupRoutesMethod->getAttributes(\Backpack\CRUD\app\Library\Attributes\DeprecatedIgnoreOnRuntime::class))) { // when the attribute is not found the developer has overwritten the method // we will keep the old behavior for backwards compatibility + LifecycleHook::trigger('crud:before_setup_routes', [$name, $routeName, $controller]); $setupRoutesMethod->invoke(App::make($namespacedController), $name, $routeName, $controller); + LifecycleHook::trigger('crud:after_setup_routes', [$name, $routeName, $controller]); return; } @@ -32,7 +35,9 @@ public static function setupControllerRoutes(string $name, string $routeName, st str_ends_with($method->getName(), 'Routes') ) { $method->setAccessible(true); + LifecycleHook::trigger('crud:before_setup_routes', [$name, $routeName, $controller]); $method->invoke($controllerInstance, $name, $routeName, $controller); + LifecycleHook::trigger('crud:after_setup_routes', [$name, $routeName, $controller]); } } } diff --git a/src/app/Library/CrudPanel/Hooks/Facades/LifecycleHook.php b/src/app/Library/CrudPanel/Hooks/Facades/LifecycleHook.php new file mode 100644 index 0000000000..6401e95a2b --- /dev/null +++ b/src/app/Library/CrudPanel/Hooks/Facades/LifecycleHook.php @@ -0,0 +1,20 @@ +hooks[$controller][$hook][] = $callback; + } + } + + public function trigger(string|array $hooks, array $parameters = []): void + { + $hooks = is_array($hooks) ? $hooks : [$hooks]; + $controller = CrudManager::getActiveController() ?? CrudManager::getParentController(); + + foreach ($hooks as $hook) { + // Create a unique identifier for this controller+hook combination + // Include the full hook name (which includes operation) to ensure uniqueness per operation + $hookId = is_null($controller) ? $hook : (is_string($controller) ? $controller : $controller::class).'::'.$hook; + + // Skip if this hook has already been executed + if (isset($this->executedHooks[$hookId])) { + continue; + } + + if (isset($this->hooks[$controller][$hook])) { + foreach ($this->hooks[$controller][$hook] as $callback) { + if ($callback instanceof \Closure) { + $callback(...$parameters); + } + } + + $this->executedHooks[$hookId] = true; + } + } + } + + public function has(string $hook): bool + { + $controller = CrudManager::getActiveController() ?? CrudManager::getParentController(); + + return isset($this->hooks[$controller][$hook]); + } +} diff --git a/src/app/Library/CrudPanel/Traits/AutoSet.php b/src/app/Library/CrudPanel/Traits/AutoSet.php index 1944278ff6..bf3471164b 100644 --- a/src/app/Library/CrudPanel/Traits/AutoSet.php +++ b/src/app/Library/CrudPanel/Traits/AutoSet.php @@ -30,7 +30,7 @@ public function setFromDb($setFields = true, $setColumns = true) ]); } - if ($setColumns && ! in_array($field, $this->model->getHidden()) && ! isset($this->columns()[$field])) { + if ($setColumns && ! in_array($field, $this->getModel()->getHidden()) && ! isset($this->columns()[$field])) { $this->addColumn([ 'name' => $field, 'label' => $this->makeLabel($field), diff --git a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php index c93e0fba4d..0d55ac030b 100644 --- a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php +++ b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php @@ -138,7 +138,7 @@ protected function makeSureFieldHasName($field) */ protected function makeSureFieldHasEntity($field) { - $model = isset($field['baseModel']) ? (new $field['baseModel']) : $this->model; + $model = isset($field['baseModel']) ? (new $field['baseModel']) : $this->getModel(); if (isset($field['entity'])) { return $field; @@ -281,6 +281,8 @@ protected function makeSureSubfieldsHaveNecessaryAttributes($field) $subfield['name'] = Str::replace(' ', '', $subfield['name']); $subfield['parentFieldName'] = $field['name']; + $subfield['baseFieldName'] = is_array($subfield['name']) ? implode(',', $subfield['name']) : $subfield['name']; + $subfield['baseFieldName'] = Str::afterLast($subfield['baseFieldName'], '.'); if (! isset($field['model'])) { // we're inside a simple 'repeatable' with no model/relationship, so @@ -294,8 +296,6 @@ protected function makeSureSubfieldsHaveNecessaryAttributes($field) $currentEntity = $subfield['baseEntity'] ?? $field['entity']; $subfield['baseModel'] = $subfield['baseModel'] ?? $field['model']; $subfield['baseEntity'] = isset($field['baseEntity']) ? $field['baseEntity'].'.'.$currentEntity : $currentEntity; - $subfield['baseFieldName'] = is_array($subfield['name']) ? implode(',', $subfield['name']) : $subfield['name']; - $subfield['baseFieldName'] = Str::afterLast($subfield['baseFieldName'], '.'); } $field['subfields'][$key] = $this->makeSureFieldHasNecessaryAttributes($subfield); diff --git a/src/app/Library/CrudPanel/Traits/Operations.php b/src/app/Library/CrudPanel/Traits/Operations.php index 3a3c94c395..373b3ea0fe 100644 --- a/src/app/Library/CrudPanel/Traits/Operations.php +++ b/src/app/Library/CrudPanel/Traits/Operations.php @@ -2,6 +2,8 @@ namespace Backpack\CRUD\app\Library\CrudPanel\Traits; +use Backpack\CRUD\app\Library\CrudPanel\Hooks\Facades\LifecycleHook; + trait Operations { /* @@ -59,30 +61,34 @@ public function setCurrentOperation($operation_name) * @param string|array $operation Operation name in string form * @param bool|\Closure $closure Code that calls CrudPanel methods. * @return void + * + * @deprecated use LifecycleHook::hookInto($operation.':before_setup', $closure) instead */ public function operation($operations, $closure = false) { - return $this->configureOperation($operations, $closure); + $operations = is_array($operations) ? $operations : [$operations]; + foreach ($operations as $operation) { + LifecycleHook::hookInto($operation.':before_setup', $closure); + } } /** * Store a closure which configures a certain operation inside settings. - * Allc configurations are put inside that operation's namespace. + * All configurations are put inside that operation's namespace. * Ex: show.configuration. * * @param string|array $operation Operation name in string form * @param bool|\Closure $closure Code that calls CrudPanel methods. * @return void + * + * @deprecated use LifecycleHook::hookInto($operation.':before_setup', $closure) instead */ public function configureOperation($operations, $closure = false) { - $operations = (array) $operations; + $operations = is_array($operations) ? $operations : [$operations]; foreach ($operations as $operation) { - $configuration = (array) $this->get($operation.'.configuration'); - $configuration[] = $closure; - - $this->set($operation.'.configuration', $configuration); + LifecycleHook::hookInto($operation.':before_setup', $closure); } } @@ -91,24 +97,15 @@ public function configureOperation($operations, $closure = false) * This is called when an operation does setCurrentOperation(). * * - * @param string|array $operations [description] + * @param string|array $operations * @return void */ public function applyConfigurationFromSettings($operations) { - $operations = (array) $operations; + $operations = is_array($operations) ? $operations : [$operations]; foreach ($operations as $operation) { - $configuration = (array) $this->get($operation.'.configuration'); - - if (count($configuration)) { - foreach ($configuration as $closure) { - if (is_callable($closure)) { - // apply the closure - ($closure)(); - } - } - } + LifecycleHook::trigger($operation.':before_setup'); } } } diff --git a/src/app/Library/CrudPanel/Traits/Query.php b/src/app/Library/CrudPanel/Traits/Query.php index a14d3817c5..4210e9a13d 100644 --- a/src/app/Library/CrudPanel/Traits/Query.php +++ b/src/app/Library/CrudPanel/Traits/Query.php @@ -277,7 +277,6 @@ private function getCountFromQuery(Builder $query) } // re-set the previous query bindings - //dump($crudQuery->getColumns(), get_class($crudQuery), get_class($subQuery)); foreach ($crudQuery->getRawBindings() as $type => $binding) { $subQuery->setBindings($binding, $type); } diff --git a/src/app/Library/CrudPanel/Traits/Search.php b/src/app/Library/CrudPanel/Traits/Search.php index 92fe3c62c4..c3c253f384 100644 --- a/src/app/Library/CrudPanel/Traits/Search.php +++ b/src/app/Library/CrudPanel/Traits/Search.php @@ -265,10 +265,13 @@ public function getRowViews($entry, $rowNumber = false) // add the buttons as the last column if ($this->buttons()->where('stack', 'line')->count()) { + $crudTableId = request()->input('datatable_id', 'crudTable'); + $row_items[] = \View::make('crud::inc.button_stack', ['stack' => 'line']) ->with('crud', $this) ->with('entry', $entry) ->with('row_number', $rowNumber) + ->with('crudTableId', $crudTableId) ->render(); } diff --git a/src/app/Library/Support/DatatableCache.php b/src/app/Library/Support/DatatableCache.php new file mode 100644 index 0000000000..9912436a38 --- /dev/null +++ b/src/app/Library/Support/DatatableCache.php @@ -0,0 +1,203 @@ +cachePrefix = 'datatable_config_'; + $this->cacheDuration = 60; // 1 hour + } + + /** + * Cache setup closure for a datatable component. + * + * @param string $tableId The table ID to use as cache key + * @param string $controllerClass The controller class + * @param \Closure|null $setup The setup closure + * @param string|null $name The element name + * @param CrudPanel $crud The CRUD panel instance to update with datatable_id + * @return bool Whether the operation was successful + */ + public function cacheForComponent(string $tableId, string $controllerClass, ?\Closure $setup = null, ?string $name = null, ?CrudPanel $crud = null): bool + { + if (! $setup) { + return false; + } + + $cruds = CrudManager::getCrudPanels(); + $parentCrud = reset($cruds); + + if ($parentCrud && $parentCrud->getCurrentEntry()) { + $parentEntry = $parentCrud->getCurrentEntry(); + $parentController = $parentCrud->controller; + + // Store in cache + $this->store( + $tableId, + $controllerClass, + $parentController, + $parentEntry, + $name + ); + + // Set the datatable_id in the CRUD panel if provided + if ($crud) { + $crud->set('list.datatable_id', $tableId); + } + + return true; + } + + return false; + } + + public static function applyAndStoreSetupClosure( + string $tableId, + string $controllerClass, + \Closure $setupClosure, + ?string $name = null, + ?CrudPanel $crud = null, + $parentEntry = null + ): bool { + $instance = new self(); + // Cache the setup closure for the datatable component + if ($instance->applySetupClosure($crud, $controllerClass, $setupClosure, $parentEntry)) { + // Apply the setup closure to the CrudPanel instance + return $instance->cacheForComponent($tableId, $controllerClass, $setupClosure, $name, $crud); + } + + return false; + } + + /** + * Apply cached setup to a CRUD instance using the request's datatable_id. + * + * @param CrudPanel $crud The CRUD panel instance + * @return bool Whether the operation was successful + */ + public static function applyFromRequest(CrudPanel $crud): bool + { + $instance = new self(); + // Check if the request has a datatable_id parameter + $tableId = request('datatable_id'); + + if (! $tableId) { + \Log::debug('Missing datatable_id in request parameters'); + + return false; + } + + return $instance->apply($tableId, $crud); + } + + /** + * Apply a setup closure to a CrudPanel instance. + * + * @param CrudPanel $crud The CRUD panel instance + * @param string $controllerClass The controller class + * @param \Closure $setupClosure The setup closure + * @param mixed $entry The entry to pass to the setup closure + * @return bool Whether the operation was successful + */ + public function applySetupClosure(CrudPanel $crud, string $controllerClass, \Closure $setupClosure, $entry = null): bool + { + $originalSetup = $setupClosure; + $modifiedSetup = function ($crud, $entry) use ($originalSetup, $controllerClass) { + CrudManager::setActiveController($controllerClass); + + // Run the original closure + return ($originalSetup)($crud, $entry); + }; + + try { + // Execute the modified closure + ($modifiedSetup)($crud, $entry); + + return true; + } finally { + // Clean up + CrudManager::unsetActiveController(); + } + } + + /** + * Prepare datatable data for storage in the cache. + * + * @param string $controllerClass The controller class + * @param string $parentController The parent controller + * @param mixed $parentEntry The parent entry + * @param string|null $elementName The element name + * @return array The data to be cached + */ + protected function prepareDataForStorage(...$args): array + { + [$controllerClass, $parentController, $parentEntry, $elementName] = $args; + + return [ + 'controller' => $controllerClass, + 'parentController' => $parentController, + 'parent_entry' => $parentEntry, + 'element_name' => $elementName, + 'operations' => CrudManager::getInitializedOperations($parentController), + ]; + } + + /** + * Apply data from the cache to configure a datatable. + * + * @param array $cachedData The cached data + * @param CrudPanel $crud The CRUD panel instance + * @return bool Whether the operation was successful + */ + protected function applyFromCache($cachedData, ...$args): bool + { + [$crud] = $args; + + try { + // Initialize operations for the parent controller + $this->initializeOperations($cachedData['parentController'], $cachedData['operations']); + $entry = $cachedData['parent_entry']; + $elementName = $cachedData['element_name']; + + $widgets = Widget::collection(); + $found = false; + + foreach ($widgets as $widget) { + if ($widget['type'] === 'datatable' && + (isset($widget['name']) && $widget['name'] === $elementName) && + (isset($widget['setup']) && $widget['setup'] instanceof \Closure)) { + $this->applySetupClosure($crud, $cachedData['controller'], $widget['setup'], $entry); + $found = true; + break; + } + } + + return $found; + } catch (\Exception $e) { + \Log::error('Error applying cached datatable config: '.$e->getMessage(), [ + 'exception' => $e, + ]); + + return false; + } + } + + /** + * Initialize operations for a parent controller. + */ + private function initializeOperations(string $parentController, $operations): void + { + $parentCrud = CrudManager::setupCrudPanel($parentController); + + foreach ($operations as $operation) { + $parentCrud->initialized = false; + CrudManager::setupCrudPanel($parentController, $operation); + } + } +} diff --git a/src/app/Library/Support/SetupCache.php b/src/app/Library/Support/SetupCache.php new file mode 100644 index 0000000000..b7aa259636 --- /dev/null +++ b/src/app/Library/Support/SetupCache.php @@ -0,0 +1,114 @@ +cachePrefix.$identifier; + } + + /** + * Store data in the cache. + */ + public function store($identifier, ...$args) + { + $cacheKey = $this->generateCacheKey($identifier); + $data = $this->prepareDataForStorage(...$args); + + if ($data !== false && $data !== null) { + Cache::forget($cacheKey); + Cache::put($cacheKey, $data, now()->addMinutes($this->cacheDuration)); + + return true; + } + + return false; + } + + /** + * Apply cached data. + */ + public function apply($identifier, ...$args) + { + $cacheKey = $this->generateCacheKey($identifier); + $cachedData = Cache::get($cacheKey); + + if (! $cachedData) { + return false; + } + + return $this->applyFromCache($cachedData, ...$args); + } + + /** + * Get cached data without applying it. + */ + public function get($identifier) + { + $cacheKey = $this->generateCacheKey($identifier); + + return Cache::get($cacheKey); + } + + /** + * Check if cache exists for the given identifier. + */ + public function has($identifier): bool + { + $cacheKey = $this->generateCacheKey($identifier); + + return Cache::has($cacheKey); + } + + /** + * Remove cached data. + */ + public function forget($identifier): bool + { + $cacheKey = $this->generateCacheKey($identifier); + + return Cache::forget($cacheKey); + } + + /** + * Set the cache prefix. + */ + public function setCachePrefix(string $prefix): self + { + $this->cachePrefix = $prefix; + + return $this; + } + + /** + * Set the cache duration in minutes. + */ + public function setCacheDuration(int $minutes): self + { + $this->cacheDuration = $minutes; + + return $this; + } + + /** + * Prepare data for storage in the cache. + * This method should be implemented by child classes. + */ + abstract protected function prepareDataForStorage(...$args); + + /** + * Apply data from the cache. + * This method should be implemented by child classes. + */ + abstract protected function applyFromCache($cachedData, ...$args); +} diff --git a/src/app/Library/Uploaders/MultipleFiles.php b/src/app/Library/Uploaders/MultipleFiles.php index e77ca33a8a..010d5c2aae 100644 --- a/src/app/Library/Uploaders/MultipleFiles.php +++ b/src/app/Library/Uploaders/MultipleFiles.php @@ -22,7 +22,7 @@ public function uploadFiles(Model $entry, $value = null) } $filesToDelete = $this->getFilesToDeleteFromRequest(); - $value = $value ?? collect(CRUD::getRequest()->file($this->getNameForRequest()))->flatten()->toArray(); + $value = $value ?? collect($value)->flatten()->toArray(); $previousFiles = $this->getPreviousFiles($entry) ?? []; if (is_array($previousFiles) && empty($previousFiles[0] ?? [])) { @@ -57,9 +57,16 @@ public function uploadFiles(Model $entry, $value = null) } } - return isset($entry->getCasts()[$this->getName()]) ? $previousFiles : json_encode($previousFiles); + $previousFiles = array_values($previousFiles); + + if (empty($previousFiles)) { + return null; + } + + return isset($entry->getCasts()[$this->getName()]) || $this->isFake() ? $previousFiles : json_encode($previousFiles); } + /** @codeCoverageIgnore */ public function uploadRepeatableFiles($files, $previousRepeatableValues, $entry = null) { $fileOrder = $this->getFileOrderFromRequest(); @@ -73,11 +80,26 @@ public function uploadRepeatableFiles($files, $previousRepeatableValues, $entry } } } + // create a temporary variable that we can unset keys + // everytime one is found. That way we avoid iterating + // already handled keys (notice we do a deep array copy) + $tempFileOrder = array_map(function ($item) { + return $item; + }, $fileOrder); foreach ($previousRepeatableValues as $previousRow => $previousFiles) { foreach ($previousFiles ?? [] as $key => $file) { - $key = array_search($file, $fileOrder, true); - if ($key === false) { + $previousFileInArray = array_filter($tempFileOrder, function ($items, $key) use ($file, $tempFileOrder) { + $found = array_search($file, $items ?? [], true); + if ($found !== false) { + Arr::forget($tempFileOrder, $key.'.'.$found); + + return true; + } + + return false; + }, ARRAY_FILTER_USE_BOTH); + if ($file && ! $previousFileInArray) { Storage::disk($this->getDisk())->delete($file); } } @@ -86,12 +108,12 @@ public function uploadRepeatableFiles($files, $previousRepeatableValues, $entry return $fileOrder; } - protected function hasDeletedFiles($value): bool + public function hasDeletedFiles($value): bool { return empty($this->getFilesToDeleteFromRequest()) ? false : true; } - protected function getEntryAttributeValue(Model $entry) + public function getEntryAttributeValue(Model $entry) { $value = $entry->{$this->getAttributeName()}; diff --git a/src/app/Library/Uploaders/SingleBase64Image.php b/src/app/Library/Uploaders/SingleBase64Image.php index 97c4e27488..71ffde3d73 100644 --- a/src/app/Library/Uploaders/SingleBase64Image.php +++ b/src/app/Library/Uploaders/SingleBase64Image.php @@ -7,11 +7,11 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +/** @codeCoverageIgnore */ class SingleBase64Image extends Uploader { public function uploadFiles(Model $entry, $value = null) { - $value = $value ?? CRUD::getRequest()->get($this->getName()); $previousImage = $this->getPreviousFiles($entry); if (! $value && $previousImage) { @@ -51,7 +51,7 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry } } - $imagesToDelete = array_diff($previousRepeatableValues, $values); + $imagesToDelete = array_diff(array_filter($previousRepeatableValues), $values); foreach ($imagesToDelete as $image) { Storage::disk($this->getDisk())->delete($image); @@ -60,13 +60,18 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry return $values; } - protected function shouldUploadFiles($value): bool + public function shouldUploadFiles($value): bool { return $value && is_string($value) && Str::startsWith($value, 'data:image'); } - protected function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool + public function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool { return $entry->exists && is_string($entryValue) && ! Str::startsWith($entryValue, 'data:image'); } + + public function getUploadedFilesFromRequest() + { + return CRUD::getRequest()->get($this->getNameForRequest()); + } } diff --git a/src/app/Library/Uploaders/SingleFile.php b/src/app/Library/Uploaders/SingleFile.php index 7a3f1318d9..0b4acf6055 100644 --- a/src/app/Library/Uploaders/SingleFile.php +++ b/src/app/Library/Uploaders/SingleFile.php @@ -10,7 +10,6 @@ class SingleFile extends Uploader { public function uploadFiles(Model $entry, $value = null) { - $value = $value ?? CrudPanelFacade::getRequest()->file($this->getName()); $previousFile = $this->getPreviousFiles($entry); if ($value === false && $previousFile) { @@ -38,6 +37,7 @@ public function uploadFiles(Model $entry, $value = null) return $previousFile; } + /** @codeCoverageIgnore */ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry = null) { $orderedFiles = $this->getFileOrderFromRequest(); @@ -53,9 +53,13 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry } foreach ($previousRepeatableValues as $row => $file) { - if ($file && ! isset($orderedFiles[$row])) { - $orderedFiles[$row] = null; - Storage::disk($this->getDisk())->delete($file); + if ($file) { + if (! isset($orderedFiles[$row])) { + $orderedFiles[$row] = null; + } + if (! in_array($file, $orderedFiles)) { + Storage::disk($this->getDisk())->delete($file); + } } } @@ -65,17 +69,17 @@ public function uploadRepeatableFiles($values, $previousRepeatableValues, $entry /** * Single file uploaders send no value when they are not dirty. */ - protected function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool + public function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool { return is_string($entryValue); } - protected function hasDeletedFiles($entryValue): bool + public function hasDeletedFiles($entryValue): bool { return $entryValue === null; } - protected function shouldUploadFiles($value): bool + public function shouldUploadFiles($value): bool { return is_a($value, 'Illuminate\Http\UploadedFile', true); } diff --git a/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php b/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php index 185350c053..e8e432ecc6 100644 --- a/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php +++ b/src/app/Library/Uploaders/Support/Interfaces/UploaderInterface.php @@ -15,10 +15,14 @@ public static function for(array $field, array $configuration): UploaderInterfac /** * Default implementation functions. */ + + // method called on `saving` event to store and update the entry with the uploaded files public function storeUploadedFiles(Model $entry); + // method called on `retrieved` event to populated the uploaded files in the entry public function retrieveUploadedFiles(Model $entry); + // method called on `deleting` event to delete the uploaded files public function deleteUploadedFiles(Model $entry); /** @@ -30,6 +34,8 @@ public function repeats(string $repeatableContainerName): self; public function relationship(bool $isRelation): self; + public function fake(bool|string $isFake): self; + /** * Getters. */ @@ -53,11 +59,20 @@ public function getIdentifier(): string; public function getNameForRequest(): string; - public function shouldDeleteFiles(): bool; - public function canHandleMultipleFiles(): bool; public function isRelationship(): bool; public function getPreviousFiles(Model $entry): mixed; + + /** + * Strategy methods. + */ + public function shouldDeleteFiles(): bool; + + public function hasDeletedFiles($entryValue): bool; + + public function shouldUploadFiles(mixed $value): bool; + + public function shouldKeepPreviousValueUnchanged(Model $entry, mixed $entryValue): bool; } diff --git a/src/app/Library/Uploaders/Support/RegisterUploadEvents.php b/src/app/Library/Uploaders/Support/RegisterUploadEvents.php index 237f90ce85..3fedbc7175 100644 --- a/src/app/Library/Uploaders/Support/RegisterUploadEvents.php +++ b/src/app/Library/Uploaders/Support/RegisterUploadEvents.php @@ -35,7 +35,7 @@ public static function handle(CrudField|CrudColumn $crudObject, array $uploaderC /******************************* * Private methods - implementation *******************************/ - private function registerEvents(array|null $subfield = [], ?bool $registerModelEvents = true): void + private function registerEvents(?array $subfield = [], ?bool $registerModelEvents = true): void { if (! empty($subfield)) { $this->registerSubfieldEvent($subfield, $registerModelEvents); @@ -60,6 +60,7 @@ private function registerSubfieldEvent(array $subfield, bool $registerModelEvent $uploader = $this->getUploader($subfield, $this->uploaderConfiguration); $crudObject = $this->crudObject->getAttributes(); $uploader = $uploader->repeats($crudObject['name']); + $uploader = $uploader->fake((isset($crudObject['fake']) && $crudObject['fake']) ? ($crudObject['store_in'] ?? 'extras') : false); // If this uploader is already registered bail out. We may endup here multiple times when doing modifications to the crud object. // Changing `subfields` properties will call the macros again. We prevent duplicate entries by checking @@ -139,6 +140,14 @@ private function setupModelEvents(string $model, UploaderInterface $uploader): v $uploader->deleteUploadedFiles($entry); }); + // if the uploader is a relationship and handles repeatable files, we will also register the deleting event on the + // parent model. that way we can control the deletion of the files when the parent model is deleted. + if ($uploader->isRelationship() && $uploader->handleRepeatableFiles) { + app('crud')->model::deleting(function ($entry) use ($uploader) { + $uploader->deleteUploadedFiles($entry); + }); + } + app('UploadersRepository')->markAsHandled($uploader->getIdentifier()); } @@ -154,9 +163,13 @@ private function setupModelEvents(string $model, UploaderInterface $uploader): v */ private function getUploader(array $crudObject, array $uploaderConfiguration): UploaderInterface { - $customUploader = isset($uploaderConfiguration['uploader']) && class_exists($uploaderConfiguration['uploader']); + $hasCustomUploader = isset($uploaderConfiguration['uploader']); + + if ($hasCustomUploader && ! is_a($uploaderConfiguration['uploader'], UploaderInterface::class, true)) { + throw new Exception('Invalid uploader class provided for '.$this->crudObjectType.' type: '.$crudObject['type']); + } - if ($customUploader) { + if ($hasCustomUploader) { return $uploaderConfiguration['uploader']::for($crudObject, $uploaderConfiguration); } diff --git a/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php b/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php index 56f6b2f91b..84af1b95b7 100644 --- a/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php +++ b/src/app/Library/Uploaders/Support/Traits/HandleRepeatableUploads.php @@ -5,16 +5,20 @@ use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade as CRUD; use Backpack\CRUD\app\Library\Uploaders\Support\Interfaces\UploaderInterface; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +/** + * @codeCoverageIgnore + */ trait HandleRepeatableUploads { public bool $handleRepeatableFiles = false; - public null|string $repeatableContainerName = null; + public ?string $repeatableContainerName = null; /******************************* * Setters - fluently configure the uploader @@ -31,7 +35,7 @@ public function repeats(string $repeatableContainerName): self /******************************* * Getters *******************************/ - public function getRepeatableContainerName(): null|string + public function getRepeatableContainerName(): ?string { return $this->repeatableContainerName; } @@ -45,15 +49,46 @@ protected function uploadRepeatableFiles($values, $previousValues, $entry = null protected function handleRepeatableFiles(Model $entry): Model { - if ($this->isRelationship) { - return $this->processRelationshipRepeatableUploaders($entry); - } - $values = collect(CRUD::getRequest()->get($this->getRepeatableContainerName())); $files = collect(CRUD::getRequest()->file($this->getRepeatableContainerName())); + $value = $this->mergeValuesRecursive($values, $files); - $entry->{$this->getRepeatableContainerName()} = json_encode($this->processRepeatableUploads($entry, $value)); + if ($this->isRelationship()) { + if ($value->isEmpty()) { + return $entry; + } + + return $this->processRelationshipRepeatableUploaders($entry); + } + + $processedEntryValues = $this->processRepeatableUploads($entry, $value)->toArray(); + + if ($this->isFake()) { + $fakeValues = $entry->{$this->getFakeAttribute()} ?? []; + + if (is_string($fakeValues)) { + $fakeValues = json_decode($fakeValues, true); + } + + $fakeValues[$this->getRepeatableContainerName()] = empty($processedEntryValues) + ? null + : (isset($entry->getCasts()[$this->getFakeAttribute()]) + ? $processedEntryValues + : json_encode($processedEntryValues)); + + $entry->{$this->getFakeAttribute()} = isset($entry->getCasts()[$this->getFakeAttribute()]) + ? $fakeValues + : json_encode($fakeValues); + + return $entry; + } + + $entry->{$this->getRepeatableContainerName()} = empty($processedEntryValues) + ? null + : (isset($entry->getCasts()[$this->getRepeatableContainerName()]) + ? $processedEntryValues + : json_encode($processedEntryValues)); return $entry; } @@ -112,21 +147,6 @@ protected function getEntryOriginalValue(Model $entry) return $entry->getOriginal($this->getAttributeName()); } - protected function shouldUploadFiles($entryValue): bool - { - return true; - } - - protected function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool - { - return $entry->exists && ($entryValue === null || $entryValue === [null]); - } - - protected function hasDeletedFiles($entryValue): bool - { - return $entryValue === false || $entryValue === null || $entryValue === [null]; - } - protected function processRepeatableUploads(Model $entry, Collection $values): Collection { foreach (app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()) as $uploader) { @@ -150,6 +170,17 @@ private function retrieveRepeatableFiles(Model $entry): Model $repeatableUploaders = app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()); + if ($this->attachedToFakeField) { + $values = $entry->{$this->attachedToFakeField}; + + $values = is_string($values) ? json_decode($values, true) : $values; + + $values[$this->getAttributeName()] = isset($values[$this->getAttributeName()]) ? $this->getValueWithoutPath($values[$this->getAttributeName()]) : null; + $entry->{$this->attachedToFakeField} = isset($entry->getCasts()[$this->attachedToFakeField]) ? $values : json_encode($values); + + return $entry; + } + $values = $entry->{$this->getRepeatableContainerName()}; $values = is_string($values) ? json_decode($values, true) : $values; $values = array_map(function ($item) use ($repeatableUploaders) { @@ -158,7 +189,7 @@ private function retrieveRepeatableFiles(Model $entry): Model } return $item; - }, $values); + }, $values ?? []); $entry->{$this->getRepeatableContainerName()} = $values; @@ -211,7 +242,14 @@ private function deleteRepeatableFiles(Model $entry): void return; } - $repeatableValues = collect($entry->{$this->getName()}); + if ($this->attachedToFakeField) { + $repeatableValues = $entry->{$this->attachedToFakeField}[$this->getRepeatableContainerName()] ?? null; + $repeatableValues = is_string($repeatableValues) ? json_decode($repeatableValues, true) : $repeatableValues; + $repeatableValues = collect($repeatableValues); + } + + $repeatableValues ??= collect($entry->{$this->getRepeatableContainerName()}); + foreach (app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()) as $upload) { if (! $upload->shouldDeleteFiles()) { continue; @@ -273,7 +311,11 @@ protected function getFileOrderFromRequest(): array private function getPreviousRepeatableValues(Model $entry, UploaderInterface $uploader): array { - $previousValues = json_decode($entry->getOriginal($uploader->getRepeatableContainerName()), true); + $previousValues = $entry->getOriginal($uploader->getRepeatableContainerName()); + + if (! is_array($previousValues)) { + $previousValues = json_decode($previousValues, true); + } if (! empty($previousValues)) { $previousValues = array_column($previousValues, $uploader->getName()); @@ -282,70 +324,110 @@ private function getPreviousRepeatableValues(Model $entry, UploaderInterface $up return $previousValues ?? []; } - private function getValuesWithPathStripped(array|string|null $item, UploaderInterface $upload) + private function getValuesWithPathStripped(array|string|null $item, UploaderInterface $uploader) { - $uploadedValues = $item[$upload->getName()] ?? null; + $uploadedValues = $item[$uploader->getName()] ?? null; if (is_array($uploadedValues)) { - return array_map(function ($value) use ($upload) { - return Str::after($value, $upload->getPath()); + return array_map(function ($value) use ($uploader) { + return $uploader->getValueWithoutPath($value); }, $uploadedValues); } - return isset($uploadedValues) ? Str::after($uploadedValues, $upload->getPath()) : null; + return isset($uploadedValues) ? $uploader->getValueWithoutPath($uploadedValues) : null; } private function deleteRelationshipFiles(Model $entry): void { + if (! is_a($entry, Pivot::class, true) && + ! $entry->relationLoaded($this->getRepeatableContainerName()) && + method_exists($entry, $this->getRepeatableContainerName()) + ) { + $entry->loadMissing($this->getRepeatableContainerName()); + } + foreach (app('UploadersRepository')->getRepeatableUploadersFor($this->getRepeatableContainerName()) as $uploader) { - $uploader->deleteRepeatableRelationFiles($entry); + if ($uploader->shouldDeleteFiles()) { + $uploader->deleteRepeatableRelationFiles($entry); + } } } - private function deleteRepeatableRelationFiles(Model $entry) + protected function deleteRepeatableRelationFiles(Model $entry) { - if (in_array($this->getRepeatableRelationType(), ['BelongsToMany', 'MorphToMany'])) { - $pivotAttributes = $entry->getAttributes(); - $connectedPivot = $entry->pivotParent->{$this->getRepeatableContainerName()}->where(function ($item) use ($pivotAttributes) { - $itemPivotAttributes = $item->pivot->only(array_keys($pivotAttributes)); + match ($this->getRepeatableRelationType()) { + 'BelongsToMany', 'MorphToMany' => $this->deletePivotFiles($entry), + default => $this->deleteRelatedFiles($entry), + }; + } - return $itemPivotAttributes === $pivotAttributes; - })->first(); + private function deleteRelatedFiles(Model $entry) + { + if (get_class($entry) === get_class(app('crud')->model)) { + $relatedEntries = $entry->{$this->getRepeatableContainerName()} ?? []; + } - if (! $connectedPivot) { - return; - } + if (! is_a($relatedEntries ?? '', Collection::class, true)) { + $relatedEntries = ! empty($relatedEntries) ? [$relatedEntries] : [$entry]; + } - $files = $connectedPivot->getOriginal()['pivot_'.$this->getAttributeName()]; + foreach ($relatedEntries as $relatedEntry) { + $this->deleteFiles($relatedEntry); + } + } - if (! $files) { - return; + protected function deletePivotFiles(Pivot|Model $entry) + { + if (! is_a($entry, Pivot::class, true)) { + $pivots = $entry->{$this->getRepeatableContainerName()}; + foreach ($pivots as $pivot) { + $this->deletePivotModelFiles($pivot); } - if ($this->handleMultipleFiles && is_string($files)) { - try { - $files = json_decode($files, true); - } catch (\Exception) { - Log::error('Could not parse files for deletion pivot entry with key: '.$entry->getKey().' and uploader: '.$this->getName()); + return; + } + + $pivotAttributes = $entry->getAttributes(); + $connectedPivot = $entry->pivotParent->{$this->getRepeatableContainerName()}->where(function ($item) use ($pivotAttributes) { + $itemPivotAttributes = $item->pivot->only(array_keys($pivotAttributes)); - return; - } - } + return $itemPivotAttributes === $pivotAttributes; + })->first(); - if (is_array($files)) { - foreach ($files as $value) { - $value = Str::start($value, $this->getPath()); - Storage::disk($this->getDisk())->delete($value); - } + if (! $connectedPivot) { + return; + } + + $this->deletePivotModelFiles($connectedPivot); + } + + private function deletePivotModelFiles(Pivot|Model $entry) + { + $files = $entry->getOriginal()['pivot_'.$this->getAttributeName()]; + + if (! $files) { + return; + } + + if ($this->handleMultipleFiles && is_string($files)) { + try { + $files = json_decode($files, true); + } catch (\Exception) { + Log::error('Could not parse files for deletion pivot entry with key: '.$entry->getKey().' and uploader: '.$this->getName()); return; } + } - $value = Str::start($files, $this->getPath()); - Storage::disk($this->getDisk())->delete($value); + if (is_array($files)) { + foreach ($files as $value) { + $value = Str::start($value, $this->getPath()); + Storage::disk($this->getDisk())->delete($value); + } return; } - $this->deleteFiles($entry); + $value = Str::start($files, $this->getPath()); + Storage::disk($this->getDisk())->delete($value); } } diff --git a/src/app/Library/Uploaders/Support/UploadersRepository.php b/src/app/Library/Uploaders/Support/UploadersRepository.php index a751820332..0bbd438324 100644 --- a/src/app/Library/Uploaders/Support/UploadersRepository.php +++ b/src/app/Library/Uploaders/Support/UploadersRepository.php @@ -2,7 +2,10 @@ namespace Backpack\CRUD\app\Library\Uploaders\Support; +use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade as CRUD; use Backpack\CRUD\app\Library\Uploaders\Support\Interfaces\UploaderInterface; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; final class UploadersRepository { @@ -23,7 +26,7 @@ final class UploadersRepository public function __construct() { - $this->uploaderClasses = config('backpack.crud.uploaders'); + $this->uploaderClasses = config('backpack.crud.uploaders', []); } /** @@ -49,7 +52,7 @@ public function isUploadHandled(string $objectName): bool */ public function hasUploadFor(string $objectType, string $group): bool { - return array_key_exists($objectType, $this->uploaderClasses[$group]); + return array_key_exists($objectType, $this->uploaderClasses[$group] ?? []); } /** @@ -57,14 +60,32 @@ public function hasUploadFor(string $objectType, string $group): bool */ public function getUploadFor(string $objectType, string $group): string { + if (! $this->hasUploadFor($objectType, $group)) { + throw new \Exception('There is no uploader defined for the given field type.'); + } + return $this->uploaderClasses[$group][$objectType]; } + /** + * return the registered groups names AKA macros. eg: withFiles, withMedia. + */ + public function getUploadersGroupsNames(): array + { + return array_keys($this->uploaderClasses); + } + /** * Register new uploaders or override existing ones. */ public function addUploaderClasses(array $uploaders, string $group): void { + // ensure all uploaders implement the UploaderInterface + foreach ($uploaders as $uploader) { + if (! is_a($uploader, UploaderInterface::class, true)) { + throw new \Exception('The uploader class must implement the UploaderInterface.'); + } + } $this->uploaderClasses[$group] = array_merge($this->getGroupUploadersClasses($group), $uploaders); } @@ -119,4 +140,90 @@ public function getRegisteredUploadNames(string $uploadName): array return $uploader->getName(); }, $this->getRepeatableUploadersFor($uploadName)); } + + /** + * Get the uploaders classes for the given group of uploaders. + */ + public function getAjaxUploadTypes(string $uploaderMacro = 'withFiles'): array + { + $ajaxFieldTypes = []; + foreach ($this->uploaderClasses[$uploaderMacro] as $fieldType => $uploader) { + if (is_a($uploader, 'Backpack\Pro\Uploads\BackpackAjaxUploader', true)) { + $ajaxFieldTypes[] = $fieldType; + } + } + + return $ajaxFieldTypes; + } + + /** + * Get an ajax uploader instance for a given input name. + */ + public function getFieldUploaderInstance(string $requestInputName): UploaderInterface + { + if (strpos($requestInputName, '#') !== false) { + $repeatableContainerName = Str::before($requestInputName, '#'); + $requestInputName = Str::after($requestInputName, '#'); + + $uploaders = $this->getRepeatableUploadersFor($repeatableContainerName); + + $uploader = Arr::first($uploaders, function ($uploader) use ($requestInputName) { + return $uploader->getName() === $requestInputName; + }); + + if (! $uploader) { + abort(500, 'Could not find the field in the repeatable uploaders.'); + } + + return $uploader; + } + + if (empty($crudObject = CRUD::fields()[$requestInputName] ?? [])) { + abort(500, 'Could not find the field in the CRUD fields.'); + } + + if (! $uploaderMacro = $this->getUploadCrudObjectMacroType($crudObject)) { + abort(500, 'There is no uploader defined for the given field type.'); + } + + if (! $this->isValidUploadField($crudObject, $uploaderMacro)) { + abort(500, 'Invalid field for upload.'); + } + + $uploaderConfiguration = $crudObject[$uploaderMacro] ?? []; + $uploaderConfiguration = ! is_array($uploaderConfiguration) ? [] : $uploaderConfiguration; + $uploaderClass = $this->getUploadFor($crudObject['type'], $uploaderMacro); + + return new $uploaderClass(['name' => $requestInputName], $uploaderConfiguration); + } + + /** + * Get the upload field macro type for the given object. + */ + private function getUploadCrudObjectMacroType(array $crudObject): ?string + { + $uploadersGroups = $this->getUploadersGroupsNames(); + + foreach ($uploadersGroups as $uploaderMacro) { + if (isset($crudObject[$uploaderMacro])) { + return $uploaderMacro; + } + } + + return null; + } + + private function isValidUploadField($crudObject, $uploaderMacro) + { + if (Str::contains($crudObject['name'], '#')) { + $container = Str::before($crudObject['name'], '#'); + $field = array_filter(CRUD::fields()[$container]['subfields'] ?? [], function ($item) use ($crudObject, $uploaderMacro) { + return $item['name'] === $crudObject['name'] && in_array($item['type'], $this->getAjaxUploadTypes($uploaderMacro)); + }); + + return ! empty($field); + } + + return in_array($crudObject['type'], $this->getAjaxUploadTypes($uploaderMacro)); + } } diff --git a/src/app/Library/Uploaders/Uploader.php b/src/app/Library/Uploaders/Uploader.php index eb298a89f6..a11295aeb7 100644 --- a/src/app/Library/Uploaders/Uploader.php +++ b/src/app/Library/Uploaders/Uploader.php @@ -2,6 +2,7 @@ namespace Backpack\CRUD\app\Library\Uploaders; +use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade as CRUD; use Backpack\CRUD\app\Library\Uploaders\Support\Interfaces\UploaderInterface; use Backpack\CRUD\app\Library\Uploaders\Support\Traits\HandleFileNaming; use Backpack\CRUD\app\Library\Uploaders\Support\Traits\HandleRepeatableUploads; @@ -21,7 +22,7 @@ abstract class Uploader implements UploaderInterface private string $path = ''; - private bool $handleMultipleFiles = false; + public bool $handleMultipleFiles = false; private bool $deleteWhenEntryIsDeleted = true; @@ -76,17 +77,19 @@ public function storeUploadedFiles(Model $entry): Model return $this->handleRepeatableFiles($entry); } + $values = $this->getUploadedFilesFromRequest(); + if ($this->attachedToFakeField) { $fakeFieldValue = $entry->{$this->attachedToFakeField}; $fakeFieldValue = is_string($fakeFieldValue) ? json_decode($fakeFieldValue, true) : (array) $fakeFieldValue; - $fakeFieldValue[$this->getAttributeName()] = $this->uploadFiles($entry); + $fakeFieldValue[$this->getAttributeName()] = $this->uploadFiles($entry, $values); $entry->{$this->attachedToFakeField} = isset($entry->getCasts()[$this->attachedToFakeField]) ? $fakeFieldValue : json_encode($fakeFieldValue); return $entry; } - $entry->{$this->getAttributeName()} = $this->uploadFiles($entry); + $entry->{$this->getAttributeName()} = $this->uploadFiles($entry, $values); return $entry; } @@ -102,16 +105,14 @@ public function retrieveUploadedFiles(Model $entry): Model public function deleteUploadedFiles(Model $entry): void { - if ($this->deleteWhenEntryIsDeleted) { - if (! in_array(SoftDeletes::class, class_uses_recursive($entry), true)) { - $this->performFileDeletion($entry); + if (! in_array(SoftDeletes::class, class_uses_recursive($entry), true)) { + $this->performFileDeletion($entry); - return; - } + return; + } - if ($entry->isForceDeleting() === true) { - $this->performFileDeletion($entry); - } + if ($entry->isForceDeleting() === true) { + $this->performFileDeletion($entry); } } @@ -153,6 +154,21 @@ public function shouldDeleteFiles(): bool return $this->deleteWhenEntryIsDeleted; } + public function shouldUploadFiles($entryValue): bool + { + return true; + } + + public function shouldKeepPreviousValueUnchanged(Model $entry, $entryValue): bool + { + return $entry->exists && ($entryValue === null || $entryValue === [null]); + } + + public function hasDeletedFiles($entryValue): bool + { + return $entryValue === false || $entryValue === null || $entryValue === [null]; + } + public function getIdentifier(): string { if ($this->handleRepeatableFiles) { @@ -182,13 +198,32 @@ public function getPreviousFiles(Model $entry): mixed if (! $this->attachedToFakeField) { return $this->getOriginalValue($entry); } - $value = $this->getOriginalValue($entry, $this->attachedToFakeField); $value = is_string($value) ? json_decode($value, true) : (array) $value; return $value[$this->getAttributeName()] ?? null; } + public function getValueWithoutPath(?string $value = null): ?string + { + return $value ? Str::after($value, $this->path) : null; + } + + public function getUploadedFilesFromRequest() + { + return CRUD::getRequest()->file($this->getNameForRequest()); + } + + public function isFake(): bool + { + return $this->attachedToFakeField !== false; + } + + public function getFakeAttribute(): bool|string + { + return $this->attachedToFakeField; + } + /******************************* * Setters - fluently configure the uploader *******************************/ @@ -206,6 +241,13 @@ public function relationship(bool $isRelationship): self return $this; } + public function fake(bool|string $isFake): self + { + $this->attachedToFakeField = $isFake; + + return $this; + } + /******************************* * Default implementation functions *******************************/ @@ -217,32 +259,44 @@ private function retrieveFiles(Model $entry): Model { $value = $entry->{$this->getAttributeName()}; - if ($this->handleMultipleFiles) { - if (! isset($entry->getCasts()[$this->getName()]) && is_string($value)) { - $entry->{$this->getAttributeName()} = json_decode($value, true); - } + if ($this->attachedToFakeField) { + $values = $entry->{$this->attachedToFakeField}; + + $values = is_string($values) ? json_decode($values, true) : $values; + $attributeValue = $values[$this->getAttributeName()] ?? null; + $attributeValue = is_array($attributeValue) ? array_map(fn ($value) => $this->getValueWithoutPath($value), $attributeValue) : $this->getValueWithoutPath($attributeValue); + $values[$this->getAttributeName()] = $attributeValue; + $entry->{$this->attachedToFakeField} = isset($entry->getCasts()[$this->attachedToFakeField]) ? $values : json_encode($values); return $entry; } - if ($this->attachedToFakeField) { - $values = $entry->{$this->attachedToFakeField}; - $values = is_string($values) ? json_decode($values, true) : (array) $values; - - $values[$this->getAttributeName()] = isset($values[$this->getAttributeName()]) ? Str::after($values[$this->getAttributeName()], $this->path) : null; - $entry->{$this->attachedToFakeField} = json_encode($values); + if ($this->handleMultipleFiles) { + if (! isset($entry->getCasts()[$this->getName()]) && is_string($value)) { + $entry->{$this->getAttributeName()} = json_decode($value, true); + } return $entry; } - $entry->{$this->getAttributeName()} = Str::after($value, $this->path); + $entry->{$this->getAttributeName()} = $this->getValueWithoutPath($value); return $entry; } - private function deleteFiles(Model $entry) + protected function deleteFiles(Model $entry) { - $values = $entry->{$this->getAttributeName()}; + if (! $this->shouldDeleteFiles()) { + return; + } + + if ($this->attachedToFakeField) { + $values = $entry->{$this->attachedToFakeField}; + $values = is_string($values) ? json_decode($values, true) : $values; + $values = $values[$this->getAttributeName()] ?? null; + } + + $values ??= $entry->{$this->getAttributeName()}; if ($values === null) { return; @@ -250,7 +304,7 @@ private function deleteFiles(Model $entry) if ($this->handleMultipleFiles) { // ensure we have an array of values when field is not casted in model. - if (! isset($entry->getCasts()[$this->name]) && is_string($values)) { + if (is_string($values)) { $values = json_decode($values, true); } foreach ($values ?? [] as $value) { @@ -267,7 +321,7 @@ private function deleteFiles(Model $entry) private function performFileDeletion(Model $entry) { - if (! $this->handleRepeatableFiles) { + if (! $this->handleRepeatableFiles && $this->deleteWhenEntryIsDeleted) { $this->deleteFiles($entry); return; @@ -277,7 +331,7 @@ private function performFileDeletion(Model $entry) } /******************************* - * Private helper methods + * helper methods *******************************/ private function getPathFromConfiguration(array $crudObject, array $configuration): string { diff --git a/src/app/Library/Validation/Rules/BackpackCustomRule.php b/src/app/Library/Validation/Rules/BackpackCustomRule.php index 5a51c4cdad..2807a69c8b 100644 --- a/src/app/Library/Validation/Rules/BackpackCustomRule.php +++ b/src/app/Library/Validation/Rules/BackpackCustomRule.php @@ -2,18 +2,22 @@ namespace Backpack\CRUD\app\Library\Validation\Rules; +use Backpack\CRUD\app\Library\Validation\Rules\Support\ValidateArrayContract; +use Backpack\Pro\Uploads\Validation\ValidGenericAjaxEndpoint; use Closure; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\Rule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidatorAwareRule; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; -/** - * @method static static itemRules() - */ abstract class BackpackCustomRule implements ValidationRule, DataAwareRule, ValidatorAwareRule { + use Support\HasFiles; + /** * @var \Illuminate\Contracts\Validation\Validator */ @@ -30,6 +34,12 @@ public static function field(string|array|ValidationRule|Rule $rules = []): self $instance = new static(); $instance->fieldRules = self::getRulesAsArray($rules); + if ($instance->validatesArrays()) { + if (! in_array('array', $instance->getFieldRules())) { + $instance->fieldRules[] = 'array'; + } + } + return $instance; } @@ -43,7 +53,18 @@ public static function field(string|array|ValidationRule|Rule $rules = []): self */ public function validate(string $attribute, mixed $value, Closure $fail): void { - // is the extending class reponsability the implementation of the validation logic + $value = $this->ensureValueIsValid($value); + + if ($value === false) { + $fail('Invalid value for the attribute.')->translate(); + + return; + } + + $errors = $this->validateOnSubmit($attribute, $value); + foreach ($errors as $error) { + $fail($error)->translate(); + } } /** @@ -96,19 +117,120 @@ protected static function getRulesAsArray($rules) return $rules; } + protected function ensureValueIsValid($value) + { + if ($this->validatesArrays() && ! is_array($value)) { + try { + $value = json_decode($value, true) ?? []; + } catch(\Exception $e) { + return false; + } + } + + return $value; + } + + private function validatesArrays(): bool + { + return is_a($this, ValidateArrayContract::class); + } + + private function validateAndGetErrors(string $attribute, mixed $value, array $rules): array + { + $validator = Validator::make($value, [ + $attribute => $rules, + ], $this->validator->customMessages, $this->getValidatorCustomAttributes($attribute)); + + return $validator->errors()->messages()[$attribute] ?? (! empty($validator->errors()->messages()) ? current($validator->errors()->messages()) : []); + } + + private function getValidatorCustomAttributes(string $attribute): array + { + if (! is_a($this, ValidGenericAjaxEndpoint::class) && ! Str::contains($attribute, '.*.')) { + return $this->validator->customAttributes; + } + + // generic fallback to `profile picture` from `profile.*.picture` + return [$attribute => Str::replace('.*.', ' ', $attribute)]; + } + + protected function getValidationAttributeString(string $attribute) + { + return Str::substrCount($attribute, '.') > 1 ? + Str::before($attribute, '.').'.*.'.Str::afterLast($attribute, '.') : + $attribute; + } + + protected function validateOnSubmit(string $attribute, mixed $value): array + { + return $this->validateRules($attribute, $value); + } + + protected function validateFieldAndFile(string $attribute, ?array $data = null, ?array $customRules = null): array + { + $fieldErrors = $this->validateFieldRules($attribute, $data, $customRules); + + $fileErrors = $this->validateFileRules($attribute, $data); + + return array_merge($fieldErrors, $fileErrors); + } + /** * Implementation. */ - public function validateFieldRules(string $attribute, mixed $value, Closure $fail): void + public function validateFieldRules(string $attribute, null|array|string|UploadedFile $data = null, ?array $customRules = null): array { - $validator = Validator::make([$attribute => $value], [ - $attribute => $this->getFieldRules(), - ], $this->validator->customMessages, $this->validator->customAttributes); + $data = $data ?? $this->data; + $validationRuleAttribute = $this->getValidationAttributeString($attribute); + $data = $this->prepareValidatorData($data, $attribute); - if ($validator->fails()) { - foreach ($validator->errors()->messages()[$attribute] as $message) { - $fail($message)->translate(); - } + return $this->validateAndGetErrors($validationRuleAttribute, $data, $customRules ?? $this->getFieldRules()); + } + + protected function prepareValidatorData(array|string|UploadedFile $data, string $attribute): array + { + if ($this->validatesArrays() && is_array($data) && ! Str::contains($attribute, '.')) { + return Arr::has($data, $attribute) ? $data : [$attribute => $data]; } + + if (Str::contains($attribute, '.')) { + $validData = []; + + Arr::set($validData, $attribute, ! is_array($data) ? $data : Arr::get($data, $attribute)); + + return $validData; + } + + return [$attribute => is_array($data) ? (Arr::has($data, $attribute) ? Arr::get($data, $attribute) : $data) : $data]; + } + + protected function validateFileRules(string $attribute, mixed $data): array + { + $items = $this->prepareValidatorData($data ?? $this->data, $attribute); + $items = is_array($items) ? $items : [$items]; + $validationRuleAttribute = $this->getValidationAttributeString($attribute); + + $filesToValidate = Arr::get($items, $attribute); + $filesToValidate = is_array($filesToValidate) ? array_filter($filesToValidate, function ($item) { + return $item instanceof UploadedFile; + }) : (is_a($filesToValidate, UploadedFile::class, true) ? [$filesToValidate] : []); + + Arr::set($items, $attribute, $filesToValidate); + + $errors = []; + + // validate each file individually + foreach ($filesToValidate as $key => $file) { + $fileToValidate = []; + Arr::set($fileToValidate, $attribute, $file); + $errors[] = $this->validateAndGetErrors($validationRuleAttribute, $fileToValidate, $this->getFileRules()); + } + + return array_unique(array_merge(...$errors)); + } + + public function validateRules(string $attribute, mixed $value): array + { + return $this->validateFieldAndFile($attribute, $value); } } diff --git a/src/app/Library/Validation/Rules/Support/ValidateArrayContract.php b/src/app/Library/Validation/Rules/Support/ValidateArrayContract.php new file mode 100644 index 0000000000..d8339a4d96 --- /dev/null +++ b/src/app/Library/Validation/Rules/Support/ValidateArrayContract.php @@ -0,0 +1,7 @@ +validateArrayData($attribute, $fail, $value); - $this->validateItems($attribute, $value, $fail); - } - - public static function field(string|array|ValidationRule|Rule $rules = []): self - { - $instance = new static(); - $instance->fieldRules = self::getRulesAsArray($rules); - - if (! in_array('array', $instance->getFieldRules())) { - $instance->fieldRules[] = 'array'; - } - - return $instance; - } - - protected function validateItems(string $attribute, array $items, Closure $fail): void - { - $cleanAttribute = Str::afterLast($attribute, '.'); - foreach ($items as $file) { - $validator = Validator::make([$cleanAttribute => $file], [ - $cleanAttribute => $this->getFileRules(), - ], $this->validator->customMessages, $this->validator->customAttributes); - - if ($validator->fails()) { - foreach ($validator->errors()->messages() ?? [] as $attr => $message) { - foreach ($message as $messageText) { - $fail($messageText)->translate(); - } - } - } - } - } - - protected function validateArrayData(string $attribute, Closure $fail, null|array $data = null, null|array $rules = null): void - { - $data = $data ?? $this->data; - $rules = $rules ?? $this->getFieldRules(); - $validationRuleAttribute = $this->getValidationAttributeString($attribute); - $validator = Validator::make($data, [ - $validationRuleAttribute => $rules, - ], $this->validator->customMessages, $this->validator->customAttributes); - - if ($validator->fails()) { - foreach ($validator->errors()->messages()[$attribute] as $message) { - $fail($message)->translate(); - } - } - } - - protected static function ensureValidValue($value) - { - if (! is_array($value)) { - try { - $value = json_decode($value, true); - } catch (\Exception $e) { - return false; - } - } - - return $value; - } - - private function getValidationAttributeString($attribute) - { - return Str::substrCount($attribute, '.') > 1 ? - Str::before($attribute, '.').'.*.'.Str::afterLast($attribute, '.') : - $attribute; - } -} diff --git a/src/app/Library/Validation/Rules/ValidUpload.php b/src/app/Library/Validation/Rules/ValidUpload.php index b997e322e6..cd2e7e1b78 100644 --- a/src/app/Library/Validation/Rules/ValidUpload.php +++ b/src/app/Library/Validation/Rules/ValidUpload.php @@ -3,45 +3,56 @@ namespace Backpack\CRUD\app\Library\Validation\Rules; use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade; -use Backpack\CRUD\app\Library\Validation\Rules\Support\HasFiles; -use Closure; use Illuminate\Contracts\Validation\Rule; use Illuminate\Contracts\Validation\ValidationRule; -use Illuminate\Support\Facades\Validator; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; class ValidUpload extends BackpackCustomRule { - use HasFiles; - /** - * Run the validation rule. - * - * @param string $attribute - * @param mixed $value - * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail - * @return void + * Run the validation rule and return the array of errors. */ - public function validate(string $attribute, mixed $value, Closure $fail): void + public function validateRules(string $attribute, mixed $value): array { $entry = CrudPanelFacade::getCurrentEntry(); - if (! array_key_exists($attribute, $this->data) && $entry) { - return; + // if the attribute is not set in the request, and an entry exists, + // we will check if there is a previous value, as this field might not have changed. + if (! Arr::has($this->data, $attribute) && $entry) { + if (str_contains($attribute, '.') && get_class($entry) === get_class(CrudPanelFacade::getModel())) { + $previousValue = Arr::get($this->data, '_order_'.Str::before($attribute, '.')); + $previousValue = Arr::get($previousValue, Str::after($attribute, '.')); + } else { + $previousValue = Arr::get($entry, $attribute); + } + + if ($previousValue && empty($value)) { + return []; + } + + Arr::set($this->data, $attribute, $previousValue ?? $value); } - $this->validateFieldRules($attribute, $value, $fail); + // if the value is an uploaded file, or the attribute is not + // set in the request, we force fill the data with the value + if ($value instanceof UploadedFile || ! Arr::has($this->data, $attribute)) { + Arr::set($this->data, $attribute, $value); + } + + // if there are no entry, and the new value it's not a file ... well we don't want it at all. + if (! $entry && ! $value instanceof UploadedFile) { + Arr::set($this->data, $attribute, null); + } + + $fieldErrors = $this->validateFieldRules($attribute); if (! empty($value) && ! empty($this->getFileRules())) { - $validator = Validator::make([$attribute => $value], [ - $attribute => $this->getFileRules(), - ], $this->validator->customMessages, $this->validator->customAttributes); - - if ($validator->fails()) { - foreach ($validator->errors()->messages()[$attribute] as $message) { - $fail($message)->translate(); - } - } + $fileErrors = $this->validateFileRules($attribute, $value); } + + return array_merge($fieldErrors, $fileErrors ?? []); } public static function field(string|array|ValidationRule|Rule $rules = []): self diff --git a/src/app/Library/Validation/Rules/ValidUploadMultiple.php b/src/app/Library/Validation/Rules/ValidUploadMultiple.php index 02bea084c4..f0432c8751 100644 --- a/src/app/Library/Validation/Rules/ValidUploadMultiple.php +++ b/src/app/Library/Validation/Rules/ValidUploadMultiple.php @@ -3,61 +3,57 @@ namespace Backpack\CRUD\app\Library\Validation\Rules; use Backpack\CRUD\app\Library\CrudPanel\CrudPanelFacade; -use Closure; +use Backpack\CRUD\app\Library\Validation\Rules\Support\ValidateArrayContract; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; -class ValidUploadMultiple extends ValidFileArray +class ValidUploadMultiple extends BackpackCustomRule implements ValidateArrayContract { - /** - * Run the validation rule. - * - * @param string $attribute - * @param mixed $value - * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail - * @return void - */ - public function validate(string $attribute, mixed $value, Closure $fail): void + public function validateRules(string $attribute, mixed $value): array { - if (! $value = self::ensureValidValue($value)) { - $fail('Unable to determine the value type.'); - - return; - } - $entry = CrudPanelFacade::getCurrentEntry() !== false ? CrudPanelFacade::getCurrentEntry() : null; - + $data = $this->data; // `upload_multiple` sends [[0 => null]] when user doesn't upload anything // assume that nothing changed on field so nothing is sent on the request. if (count($value) === 1 && empty($value[0])) { - if ($entry) { - unset($this->data[$attribute]); - } else { - $this->data[$attribute] = []; - } + Arr::set($data, $attribute, []); $value = []; } - $previousValues = $entry?->{$attribute} ?? []; + $previousValues = str_contains($attribute, '.') ? + (Arr::get($entry?->{Str::before($attribute, '.')} ?? [], Str::after($attribute, '.')) ?? []) : + ($entry?->{$attribute} ?? []); + if (is_string($previousValues)) { $previousValues = json_decode($previousValues, true) ?? []; } - $value = array_merge($previousValues, $value); + Arr::set($data, $attribute, array_merge($previousValues, $value)); if ($entry) { $filesDeleted = CrudPanelFacade::getRequest()->input('clear_'.$attribute) ?? []; + Arr::set($data, $attribute, array_diff(Arr::get($data, $attribute), $filesDeleted)); - $data = $this->data; - $data[$attribute] = array_diff($value, $filesDeleted); + return $this->validateFieldAndFile($attribute, $data); + } - $this->validateArrayData($attribute, $fail, $data); + // if there is no entry, the values we are going to validate need to be files + // the request was tampered so we will set the attribute to null + if (! $entry && ! empty(Arr::get($data, $attribute)) && ! $this->allFiles(Arr::get($data, $attribute))) { + Arr::set($data, $attribute, null); + } - $this->validateItems($attribute, $value, $fail); + return $this->validateFieldAndFile($attribute, $data); + } - return; + private function allFiles(array $values): bool + { + foreach ($values as $value) { + if (! $value instanceof \Illuminate\Http\UploadedFile) { + return false; + } } - $this->validateArrayData($attribute, $fail); - - $this->validateItems($attribute, $value, $fail); + return true; } } diff --git a/src/app/View/Components/Dataform.php b/src/app/View/Components/Dataform.php new file mode 100644 index 0000000000..42479f1bb8 --- /dev/null +++ b/src/app/View/Components/Dataform.php @@ -0,0 +1,107 @@ +crud = CrudManager::setupCrudPanel($controller, $operation); + + $this->crud->setAutoFocusOnFirstField($this->focusOnFirstField); + + if ($this->entry && $this->operation === 'update') { + $this->action = $action ?? url($this->crud->route.'/'.$this->entry->getKey()); + $this->method = 'put'; + $this->crud->entry = $this->entry; + $this->crud->setOperationSetting('fields', $this->crud->getUpdateFields()); + } else { + $this->action = $action ?? url($this->crud->route); + } + $this->hasUploadFields = $this->crud->hasUploadFields($operation, $this->entry?->getKey()); + $this->id = $id.md5($this->action.$this->operation.$this->method.$this->controller); + + if ($this->setup) { + $this->applySetupClosure(); + } + + CrudManager::unsetActiveController(); + } + + public function applySetupClosure(): bool + { + $originalSetup = $this->setup; + $controllerClass = $this->controller; + $crud = $this->crud; + $entry = $this->entry; + + $modifiedSetup = function ($crud, $entry) use ($originalSetup, $controllerClass) { + CrudManager::setActiveController($controllerClass); + + // Run the original closure + return ($originalSetup)($crud, $entry); + }; + + try { + // Execute the modified closure + ($modifiedSetup)($crud, $entry); + + return true; + } finally { + // Clean up + CrudManager::unsetActiveController(); + } + } + + /** + * Get the view / contents that represent the component. + * + * @return \Illuminate\Contracts\View\View|\Closure|string + */ + public function render() + { + // Store the current form ID in the service container for form-aware old() helper + app()->instance('backpack.current_form_id', $this->id); + + return view('crud::components.dataform.form', [ + 'crud' => $this->crud, + 'saveAction' => $this->crud->getSaveAction(), + 'id' => $this->id, + 'name' => $this->name, + 'operation' => $this->operation, + 'action' => $this->action, + 'method' => $this->method, + 'hasUploadFields' => $this->hasUploadFields, + 'entry' => $this->entry, + ]); + } +} diff --git a/src/app/View/Components/Datagrid.php b/src/app/View/Components/Datagrid.php new file mode 100644 index 0000000000..ca01da5ed3 --- /dev/null +++ b/src/app/View/Components/Datagrid.php @@ -0,0 +1,14 @@ +crud ??= CrudManager::setupCrudPanel($controller, 'list'); + + $this->tableId = $this->generateTableId(); + + if ($this->setup) { + // Apply the configuration using DatatableCache + DatatableCache::applyAndStoreSetupClosure( + $this->tableId, + $this->controller, + $this->setup, + $this->name, + $this->crud, + $this->getParentCrudEntry() + ); + } + + if (! $this->crud->has('list.datatablesUrl')) { + $this->crud->set('list.datatablesUrl', $this->crud->getRoute()); + } + + // Reset the active controller + CrudManager::unsetActiveController(); + } + + private function getParentCrudEntry() + { + $cruds = CrudManager::getCrudPanels(); + $parentCrud = reset($cruds); + + if ($parentCrud && $parentCrud->getCurrentEntry()) { + CrudManager::storeInitializedOperation( + $parentCrud->controller, + $parentCrud->getCurrentOperation() + ); + + return $parentCrud->getCurrentEntry(); + } + + return null; + } + + private function generateTableId(): string + { + $controllerPart = str_replace('\\', '_', $this->controller); + $namePart = $this->name ?? 'default'; + $uniqueId = md5($controllerPart.'_'.$namePart); + + return 'crudTable_'.$uniqueId; + } + + public function render() + { + return view('crud::components.datatable.datatable', [ + 'crud' => $this->crud, + 'modifiesUrl' => $this->modifiesUrl, + 'tableId' => $this->tableId, + ]); + } +} diff --git a/src/app/View/Components/ShowComponent.php b/src/app/View/Components/ShowComponent.php new file mode 100644 index 0000000000..7b727e9ce6 --- /dev/null +++ b/src/app/View/Components/ShowComponent.php @@ -0,0 +1,80 @@ +setPropertiesFromController(); + } + + /** + * Set properties from the controller context. + * + * This method initializes the CrudPanel and sets the active controller. + * It also applies any setup closure provided. + */ + protected function setPropertiesFromController(): void + { + // If no CrudController is provided, do nothing + if (! $this->controller) { + return; + } + + // If no CrudPanel is provided, try to get it from the CrudManager + $this->crud ??= CrudManager::setupCrudPanel($this->controller, $this->operation); + + // Set active controller for proper context + CrudManager::setActiveController($this->controller); + + // If a setup closure is provided, apply it + if ($this->setup) { + if (! empty($columns)) { + throw new \Exception('You cannot define both setup closure and columns for a '.class_basename(static::class).' component.'); + } + + ($this->setup)($this->crud, $this->entry); + } + + $this->columns = ! empty($columns) ? $columns : $this->crud?->getOperationSetting('columns', $this->operation) ?? []; + + // Reset the active controller + CrudManager::unsetActiveController(); + } + + /** + * Get the view name for the component. + * This method must be implemented by child classes. + */ + abstract protected function getViewName(): string; + + /** + * Get the view / contents that represent the component. + */ + public function render() + { + // if no columns are set, don't load any view + if (empty($this->columns)) { + return ''; + } + + return view($this->getViewName()); + } +} diff --git a/src/config/backpack/operations/show.php b/src/config/backpack/operations/show.php index cf4410a4ee..949285b324 100644 --- a/src/config/backpack/operations/show.php +++ b/src/config/backpack/operations/show.php @@ -11,6 +11,9 @@ // To override per Controller use $this->crud->setShowContentClass('class-string') 'contentClass' => 'col-md-12', + // Which component to use for displaying the Show page? + 'component' => 'bp-datagrid', // options: bp-datagrid, bp-datalist, or a custom component alias + // Automatically add all columns from the db table? 'setFromDb' => true, diff --git a/src/helpers.php b/src/helpers.php index 059e67c2b6..aa5bf4a423 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -332,31 +332,47 @@ function square_brackets_to_dots($string) } if (! function_exists('old_empty_or_null')) { - /** - * This method is an alternative to Laravel's old() helper, which mistakenly - * returns NULL it two cases: - * - if there is an old value, and it was empty or null - * - if there is no old value - * (this is because of the ConvertsEmptyStringsToNull middleware). - * - * In contrast, this method will return: - * - the old value, if there actually is an old value for that key; - * - the second parameter, if there is no old value for that key, but it was empty string or null; - * - null, if there is no old value at all for that key; - * - * @param string $key - * @param array|string $empty_value - * @return mixed - */ - function old_empty_or_null($key, $empty_value = '') +/** + * This method is an alternative to Laravel's old() helper, which mistakenly + * returns NULL it two cases: + * - if there is an old value, and it was empty or null + * - if there is no old value + * (this is because of the ConvertsEmptyStringsToNull middleware). + * + * In contrast, this method will return: + * - the old value, if there actually is an old value for that key; + * - the second parameter, if there is no old value for that key, but it was empty string or null; + * - null, if there is no old value at all for that key; + * + * This version is form-aware to prevent old values from bleeding across multiple forms. + * + * @param string $key + * @param array|string $empty_value + * @return mixed + */ function old_empty_or_null($key, $empty_value = '') { $key = square_brackets_to_dots($key); $old_inputs = session()->getOldInput(); + // Check if we have a form ID in the old inputs to determine if this is form-specific + $submittedFormId = data_get($old_inputs, '_form_id'); + + if ($submittedFormId) { + // Check if we're currently rendering a DataForm with a specific ID + // Use Laravel's service container to get the current form context + $currentFormId = app()->bound('backpack.current_form_id') ? app('backpack.current_form_id') : null; + + // If we can determine the current form ID and it doesn't match the submitted form ID, + // don't return old values to prevent bleeding across forms + if ($currentFormId && $currentFormId !== $submittedFormId) { + return null; + } + } + // if the input name is present in the old inputs we need to return earlier and not in a coalescing chain // otherwise `null` aka empty will not pass the condition and the field value would be returned. - if (\Arr::has($old_inputs, $key)) { - return \Arr::get($old_inputs, $key) ?? $empty_value; + if (\Illuminate\Support\Arr::has($old_inputs, $key)) { + return \Illuminate\Support\Arr::get($old_inputs, $key) ?? $empty_value; } return null; diff --git a/src/macros.php b/src/macros.php index 1f455d028f..27f03f5442 100644 --- a/src/macros.php +++ b/src/macros.php @@ -36,8 +36,10 @@ } if (! CrudColumn::hasMacro('withFiles')) { CrudColumn::macro('withFiles', function ($uploadDefinition = [], $subfield = null, $registerUploaderEvents = true) { + /** @var CrudColumn $this */ $uploadDefinition = is_array($uploadDefinition) ? $uploadDefinition : []; - /** @var CrudField|CrudColumn $this */ + $this->setAttributeValue('withFiles', $uploadDefinition); + $this->save(); RegisterUploadEvents::handle($this, $uploadDefinition, 'withFiles', $subfield, $registerUploaderEvents); return $this; @@ -46,8 +48,10 @@ if (! CrudField::hasMacro('withFiles')) { CrudField::macro('withFiles', function ($uploadDefinition = [], $subfield = null, $registerUploaderEvents = true) { + /** @var CrudField $this */ $uploadDefinition = is_array($uploadDefinition) ? $uploadDefinition : []; - /** @var CrudField|CrudColumn $this */ + $this->setAttributeValue('withFiles', $uploadDefinition); + $this->save(); RegisterUploadEvents::handle($this, $uploadDefinition, 'withFiles', $subfield, $registerUploaderEvents); return $this; @@ -78,7 +82,7 @@ // if the route doesn't exist, we'll throw an exception if (! $routeInstance = Route::getRoutes()->getByName($route)) { - throw new \Exception("Route [{$route}] not found while building the link for column [{$this->attributes['name']}]."); + throw new Exception("Route [{$route}] not found while building the link for column [{$this->attributes['name']}]."); } // calculate the parameters we'll be using for the route() call @@ -92,7 +96,7 @@ $autoInferredParameter = array_diff($expectedParameters, array_keys($parameters)); if (count($autoInferredParameter) > 1) { - throw new \Exception("Route [{$route}] expects parameters [".implode(', ', $expectedParameters)."]. Insufficient parameters provided in column: [{$this->attributes['name']}]."); + throw new Exception("Route [{$route}] expects parameters [".implode(', ', $expectedParameters)."]. Insufficient parameters provided in column: [{$this->attributes['name']}]."); } $autoInferredParameter = current($autoInferredParameter) ? [current($autoInferredParameter) => function ($entry, $related_key, $column, $crud) { $entity = $crud->isAttributeInRelationString($column) ? Str::before($column['entity'], '.') : $column['entity']; @@ -110,7 +114,7 @@ try { return route($route, $parameters); - } catch (\Exception $e) { + } catch (Exception $e) { return false; } }; @@ -128,11 +132,11 @@ $route = "$entity.show"; if (! $entity) { - throw new \Exception("Entity not found while building the link for column [{$name}]."); + throw new Exception("Entity not found while building the link for column [{$name}]."); } if (! Route::getRoutes()->getByName($route)) { - throw new \Exception("Route '{$route}' not found while building the link for column [{$name}]."); + throw new Exception("Route '{$route}' not found while building the link for column [{$name}]."); } // set up the link to the show page @@ -187,6 +191,6 @@ $groupNamespace = ''; } - \Backpack\CRUD\app\Library\CrudPanel\CrudRouter::setupControllerRoutes($name, $routeName, $controller, $groupNamespace); + Backpack\CRUD\app\Library\CrudPanel\CrudRouter::setupControllerRoutes($name, $routeName, $controller, $groupNamespace); }); } diff --git a/src/resources/assets/css/common.css b/src/resources/assets/css/common.css index 7c6b25d53d..3652e9ef98 100644 --- a/src/resources/assets/css/common.css +++ b/src/resources/assets/css/common.css @@ -61,121 +61,50 @@ form .select2.select2-container { } /*Table - List View*/ -#crudTable_wrapper div.row .col-sm-12 { +.dataTables_wrapper { position: relative; + isolation: isolate; } -#crudTable_processing.dataTables_processing.card { - all: unset; - position: absolute; - background: rgba(255, 255, 255, 0.9); - height: calc(100% - 6px); - width: calc(100% - 20px); - top: 0; - left: 10px; - z-index: 999; - border-radius: 5px; -} - -#crudTable_processing.dataTables_processing.card > img { - margin: 0; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -#crudTable_processing.dataTables_processing.card > div { - display: none !important; -} - -#crudTable_wrapper #crudTable, -#crudTable_wrapper table.dataTable { - margin-top: 0 !important; -} - -#crudTable_wrapper #crudTable .crud_bulk_actions_line_checkbox { - vertical-align: text-top; -} - -#crudTable_wrapper #crudTable.dtr-none > tbody > tr > td > div.dtr-control:before, -#crudTable_wrapper table.dataTable.dtr-none > tbody > tr > td > div.dtr-control:before { - background-color: transparent; - color: #636161; - font-family: "Line Awesome Free"; - font-weight: 900; - width: 1rem; - content: "\f142"; - font-size: 21px; - box-shadow: none; - border: none; - display: inline-block; +.dataTables_wrapper div.row .col-sm-12 { position: relative; - top: 0; - left: 0; - margin: 0 0 0 -0.25rem; -} - -#crudTable_wrapper #crudTable .sorting:before, -#crudTable_wrapper #crudTable .sorting_asc:before, -#crudTable_wrapper #crudTable .sorting_desc:before, -#crudTable_wrapper #crudTable .sorting_asc_disabled:before, -#crudTable_wrapper #crudTable .sorting_desc_disabled:before, -#crudTable_wrapper table.dataTable .sorting:before, -#crudTable_wrapper table.dataTable .sorting_asc:before, -#crudTable_wrapper table.dataTable .sorting_desc:before, -#crudTable_wrapper table.dataTable .sorting_asc_disabled:before, -#crudTable_wrapper table.dataTable .sorting_desc_disabled:before { - right: 0.4em; - top: 1em; - content: "\f0d8"; - font: normal normal normal 14px/1 "Line Awesome Free"; - font-weight: 900; -} - -#crudTable_wrapper #crudTable .sorting:after, -#crudTable_wrapper #crudTable .sorting_asc:after, -#crudTable_wrapper #crudTable .sorting_desc:after, -#crudTable_wrapper #crudTable .sorting_asc_disabled:after, -#crudTable_wrapper #crudTable .sorting_desc_disabled:after, -#crudTable_wrapper table.dataTable .sorting:after, -#crudTable_wrapper table.dataTable .sorting_asc:after, -#crudTable_wrapper table.dataTable .sorting_desc:after, -#crudTable_wrapper table.dataTable .sorting_asc_disabled:after, -#crudTable_wrapper table.dataTable .sorting_desc_disabled:after { - right: 0.4em; - content: "\f0d7"; - font: normal normal normal 14px/1 "Line Awesome Free"; - font-weight: 900; -} - -#crudTable_wrapper #crudTable .crud_bulk_actions_checkbox, -#crudTable_wrapper table.dataTable .crud_bulk_actions_checkbox { - margin: 0 0.6rem 0 0.45rem; -} - -#crudTable tr th:first-child, -#crudTable tr td:first-child, -#crudTable table.dataTable tr th:first-child, -#crudTable table.dataTable tr td:first-child { - align-items: center; - padding-top: 1rem; - padding-bottom: 1rem; - padding-left: 0.6rem; } -#crudTable_wrapper .dt-buttons .dt-button-collection, -#crudTable_wrapper tr td .btn-group .dropdown-menu { - max-height: 340px; - overflow-y: auto; +.dataTables_wrapper .table-responsive { + position: relative; } -#crudTable_wrapper .dt-buttons .dt-button-collection { - padding: 0; - border: 0; +.dataTables_wrapper table.dataTable { + position: relative; + z-index: 1; +} + +/* Ensure any processing indicator within or related to table wrapper covers full area */ +.dataTables_wrapper, +div[id$="_wrapper"] { + position: relative !important; +} + +.dataTables_wrapper > .dataTables_processing, +.dataTables_wrapper > .dt-processing, +.dataTables_wrapper .dataTables_processing, +.dataTables_wrapper .dt-processing, +div[id$="_wrapper"] > .dataTables_processing, +div[id$="_wrapper"] > .dt-processing, +div[id$="_wrapper"] .dataTables_processing, +div[id$="_wrapper"] .dt-processing { + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 40px !important; + width: 100% !important; + height: calc(100% - 40px) !important; + transform: none !important; + margin: 0 !important; + z-index: 1000 !important; + inset: unset !important; } - -/*/Table - List View/*/ .navbar-filters { min-height: 25px; border-radius: 0; @@ -330,16 +259,8 @@ form .select2.select2-container { border: 1px solid rgba(0, 40, 100, 0.12) !important; } -.select2-container--bootstrap.select2-container--focus .select2-selection, -.select2-container--bootstrap.select2-container--open .select2-selection { - box-shadow: none !important; -} - -.select2-container--bootstrap .select2-dropdown { - border-color: rgba(0, 40, 100, 0.12) !important; -} - /* PACE JS */ + .pace { pointer-events: none; -webkit-user-select: none; @@ -366,7 +287,6 @@ form .select2.select2-container { text-decoration: underline !important; } -/*# sourceMappingURL=backstrap.css.map */ .noty_theme__backstrap.noty_bar { margin: 4px 0; overflow: hidden; @@ -462,7 +382,7 @@ form .select2.select2-container { } /* Use whole table width for td when displaying empty content message */ -#crudTable_wrapper td.dataTables_empty { +.crudTable_wrapper td.dataTables_empty { display: table-cell !important; } @@ -487,7 +407,6 @@ form .select2.select2-container { } /* ERRORS */ - .error_number { font-size: 156px; font-weight: 600; @@ -518,4 +437,905 @@ form .select2.select2-container { /* Summernote */ .note-editor.note-frame.fullscreen { background-color: white; -} \ No newline at end of file +} + +.dataTables_wrapper .dt-buttons .dt-button-collection { + width: auto; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu { + min-width: 100%; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu { + position: relative; + top: 0; + left: 0; + display: block; + min-width: 100%; + padding: 0; + border: 0; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li { + padding: 0; + min-width: 100%; + display: block; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li .dropdown-item { + color: #23282c; + min-width: 100%; + width: 100%; + display: block; + text-align: left; + margin: 0; + border-radius: 0; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li .dropdown-item:hover { + background: #f0f3f9; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li .dropdown-item.active { + background: #4285f4; + color: #fff; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li .dropdown-item.active:hover { + background: #4285f4; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.dropdown-divider { + height: 0; + margin: .5rem 0; + overflow: hidden; + border-top: 1px solid #e4e7ea; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-colvis { + padding: 0; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-colvis .dropdown-item { + padding: .3rem 1rem; + font-size: 0.9em; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-colvis.active .dropdown-item { + background: #4285f4; + color: #fff; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-colvis.active .dropdown-item:hover { + background: #4285f4; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-columnVisibility .dropdown-item { + padding: .3rem 1rem; + font-size: 0.9em; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-columnVisibility.active .dropdown-item { + background: #4285f4; + color: #fff; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-columnVisibility.active .dropdown-item:hover { + background: #4285f4; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-colvisRestore .dropdown-item { + padding: .3rem 1rem; + font-size: 0.9em; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-colvisRestore.active .dropdown-item { + background: #4285f4; + color: #fff; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-colvisRestore.active .dropdown-item:hover { + background: #4285f4; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-reset .dropdown-item { + padding: .3rem 1rem; + font-size: 0.9em; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-reset.active .dropdown-item { + background: #4285f4; + color: #fff; +} + +.dataTables_wrapper .dt-buttons .dt-button-collection>div.dropdown-menu ul.dropdown-menu li.buttons-reset.active .dropdown-item:hover { + background: #4285f4; +} + +.dataTables_wrapper .dataTables_filter { + text-align: right; +} + +.dataTables_wrapper .dataTables_filter input { + margin-left: 0.5em; + display: inline-block; + width: auto; +} + +.datatable_search_stack input { + display: block !important; + visibility: visible !important; + opacity: 1 !important; +} + +.dt-container .dataTables_info { + clear: both; + float: left; + padding-top: 0.755em; +} + +.dataTables_wrapper .dt-paginate { + float: right; + text-align: right; + padding-top: 0.25em; +} + +.dt-container .dt-paging .dt-paging-button { + box-sizing: border-box; + display: inline-block; + min-width: 1em; + padding: 0.1em 0.3em; + margin-left: 0px; + text-align: center; + text-decoration: none !important; + cursor: pointer; + color: #333 !important; + border: 1px solid transparent; + border-radius: 2px; + font-size: 0.85em; +} + +.dt-container .dt-paging .dt-paging-button.current, +.dt-container .dt-paging .dt-paging-button.current:hover { + color: #333 !important; + border: 1px solid #979797; + background-color: white; + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, white), color-stop(100%, #dcdcdc)); + /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, white 0%, #dcdcdc 100%); + /* Chrome10+,Safari5.1+ */ + background: -moz-linear-gradient(top, white 0%, #dcdcdc 100%); + /* FF3.6+ */ + background: -ms-linear-gradient(top, white 0%, #dcdcdc 100%); + /* IE10+ */ + background: -o-linear-gradient(top, white 0%, #dcdcdc 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, white 0%, #dcdcdc 100%); + /* W3C */ +} + +.dt-container .dt-paging .dt-paging-button.disabled, +.dt-container .dt-paging .dt-paging-button.disabled:hover, +.dt-container .dt-paging .dt-paging-button.disabled:active { + cursor: default; + color: #666 !important; + border: 1px solid transparent; + background: transparent; + box-shadow: none; +} + +table.dataTable > div.table-footer > div:nth-child(1) > div.dt-length { + display: flex; +} + +table.dataTable > div.table-footer > div:nth-child(1) > div.dt-length select { + width: 4rem; +} + +table.dataTable > div.table-footer > div:nth-child(1) > div.dt-length > label{ + white-space: nowrap; +} + +div.dt-container div.dt-paging ul.pagination { + justify-content: flex-end; +} + +table.dataTable .dt-paging .dt-paging-button:active { + outline: none; + background-color: #2b2b2b; + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #2b2b2b), color-stop(100%, #0c0c0c)); + /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* Chrome10+,Safari5.1+ */ + background: -moz-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* FF3.6+ */ + background: -ms-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* IE10+ */ + background: -o-linear-gradient(top, #2b2b2b 0%, #0c0c0c 100%); + /* Opera 11.10+ */ + background: linear-gradient(to bottom, #2b2b2b 0%, #0c0c0c 100%); + /* W3C */ + box-shadow: inset 0 0 3px #111; +} + +table.dataTable .dt-paging .ellipsis { + padding: 0 1em; +} + +table.dataTable tr th:first-child, table.dataTable tr td:first-child, table.dataTable tr th:first-child, table.dataTable tr td:first-child { + align-items: center; + padding-top: 1rem; + padding-bottom: 1rem; + padding-left: 0.6rem; +} + +/* Processing indicator - global styles */ +.dataTables_processing, +.dt-processing { + position: absolute !important; + background: rgba(255, 255, 255, 0.8) !important; + border: none !important; + box-shadow: none !important; + border-radius: 0 !important; + padding: 0 !important; + z-index: 1000 !important; + display: flex !important; + justify-content: center !important; + align-items: center !important; + overflow: hidden !important; + /* Aggressive text hiding */ + font-size: 0 !important; + line-height: 0 !important; + color: transparent !important; + text-indent: -9999px !important; + white-space: nowrap !important; + text-shadow: none !important; + text-decoration: none !important; +} + +/* Hide ALL text content and children */ +.dataTables_processing *:not(img), +.dt-processing *:not(img), +.dataTables_processing::before, +.dataTables_processing::after, +.dt-processing::before, +.dt-processing::after { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + font-size: 0 !important; + color: transparent !important; + text-indent: -9999px !important; +} + +/* Show only spinner image */ +.dataTables_processing img, +.dt-processing img { + display: block !important; + width: 40px !important; + height: 40px !important; + opacity: 1 !important; + visibility: visible !important; + margin: 0 auto !important; + position: relative !important; + z-index: 1001 !important; +} + +/* Hide when not active */ +.dataTables_processing:not([style*="block"]), +.dt-processing:not([style*="block"]) { + display: none !important; +} + +.table-content { + position: relative !important; +} + +.dataTables_wrapper table.dataTable .crud_bulk_actions_line_checkbox { + vertical-align: text-top; +} + +.dataTables_wrapper div.row .col-sm-12 { + position: relative; +} + +.dataTables_processing.card > div { + display: none !important; +} + +.dt-processing.card > div { + display: none !important; +} + +.dtr-modal .details-control, +.modal .details-control { + display: none; +} + +div.dtr-modal div.dtr-modal-content { + padding: 0; +} + +.details-row-button { + cursor: pointer; +} + +.crud-table.dtr-none > tbody > tr > td > div.dtr-control:before, +.crud-table.dtr-none > tbody > tr > td > div.dtr-control:before { + background-color: transparent; + color: #636161; + font-family: "Line Awesome Free"; + font-weight: 900; + width: 1rem; + content: "\f142"; + font-size: 21px; + box-shadow: none; + border: none; + display: inline-block; + position: relative; + top: 0; + left: 0; + margin: 0 0 0 -0.25rem; +} + +.dataTables_wrapper .dataTables_length, +.dataTables_wrapper .dataTables_filter, +.dataTables_wrapper .dataTables_info, +.dataTables_wrapper .dataTables_paginate { + color: #333; +} + +.dataTables_wrapper .dataTables_scroll { + clear: both; +} + +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody { + -webkit-overflow-scrolling: touch; +} + +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th, +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td, +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th, +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td { + vertical-align: middle; +} + +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>th>div.dataTables_sizing, +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>thead>tr>td>div.dataTables_sizing, +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>th>div.dataTables_sizing, +.dataTables_wrapper .dataTables_scroll div.dataTables_scrollBody>table>tbody>tr>td>div.dataTables_sizing { + height: 0; + overflow: hidden; + margin: 0 !important; + padding: 0 !important; +} + +div.dt-scroll-body { + border-bottom: none; +} + +.dataTables_wrapper:after { + visibility: hidden; + display: block; + content: ""; + clear: both; + height: 0; +} + +@media screen and (max-width: 767px) { + + .dataTables_wrapper .dataTables_info, + .dataTables_wrapper .dataTables_paginate { + float: none; + text-align: center; + } + + .dataTables_wrapper .dataTables_paginate { + margin-top: 0.5em; + } +} + +@media screen and (max-width: 640px) { + + .dataTables_wrapper .dataTables_length, + .dataTables_wrapper .dataTables_filter { + float: none; + text-align: center; + } + + .dataTables_wrapper .dataTables_filter { + margin-top: 0.5em; + } +} + +.dt-button.active { + background: #ccc !important; +} + +.dt-button.dropdown-item { + padding: 0.15rem 1rem; + font-size: 0.9em; +} + +.dataTables_filter { + margin-bottom: 10px; +} + +.navbar-filters div.form-group { + padding-bottom: 0 !important; +} + +.dataTables_filter label { + margin-bottom: 0; +} + +/* Adjust table headers */ +.dataTable thead th { + font-size: 0.70em; + font-weight: 700; +} + +/* Make export and column visibility buttons smaller */ +.dt-container .dt-buttons .btn-group .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.hidden { + display: none !important; + visibility: hidden !important; +} + +.dropdown-menu.show { + z-index: 9999; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice { + margin-bottom: 3px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field { + padding-left: 0; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice { + padding: 2px 6px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove { + margin-right: 3px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__clear { + margin-top: 0; + line-height: 1.5; + padding: 1px 10px; + margin-right: 8px; +} + +.select2-container--bootstrap .select2-selection--single { + padding-top: 7px; + padding-bottom: 8px; +} + +.select2-container--bootstrap .select2-selection--single .select2-selection__arrow { + top: 5px; +} + +.select2-container--bootstrap .select2-selection--single .select2-selection__clear { + margin-right: 30px; +} + +.select2-container--bootstrap .select2-selection--single .select2-selection__placeholder { + color: #999; +} + +.select2-container--bootstrap .select2-results__option { + padding: 6px 12px; +} + +.select2-container--bootstrap .select2-results__option[aria-selected=true] { + background-color: #f5f5f5; + color: #262626; +} + +.select2-container--bootstrap .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: #fff; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice { + color: #3c4858; + border: 1px solid #aaa; + border-radius: 4px; + padding: 0; + padding-right: 5px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; + padding: 0 5px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field { + background: transparent; + padding: 0 12px; + height: 36px; + line-height: 1.5; + margin-top: 0; + min-width: 5em; +} + +.select2-container--bootstrap.select2-container--focus .select2-selection, .select2-container--bootstrap.select2-container--open .select2-selection { + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6); + -webkit-transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s; + -o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; + transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; + border-color: #66afe9; +} + +.select2-container--bootstrap .select2-dropdown { + border-color: #66afe9; + overflow-x: hidden; + margin-top: -1px; +} + +.select2-container--bootstrap .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; +} + +.select2-container--bootstrap .select2-selection--single { + height: 36px; + line-height: 1.5; + padding: 6px 24px 6px 12px; +} + +.select2-container--bootstrap .select2-selection--single .select2-selection__arrow { + position: absolute; + bottom: 0; + right: 12px; + top: 0; + width: 4px; +} + +.select2-container--bootstrap .select2-selection--single .select2-selection__arrow b { + border-color: #999 transparent transparent transparent; + border-style: solid; + border-width: 4px 4px 0 4px; + height: 0; + left: 0; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--bootstrap .select2-selection--single .select2-selection__rendered { + color: #555555; + padding: 0; +} + +.select2-container--bootstrap .select2-selection--single .select2-selection__placeholder { + color: #999; +} + +.select2-container--bootstrap .select2-selection--multiple { + min-height: 36px; + padding: 0; + height: auto; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + display: block; + line-height: 1.5; + list-style: none; + margin: 0; + overflow: hidden; + padding: 0; + width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__placeholder { + color: #999; + float: left; + margin-top: 5px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice { + color: #555555; + background: #fff; + border: 1px solid #ccc; + border-radius: 4px; + cursor: default; + float: left; + margin: 5px 0 0 6px; + padding: 0 6px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field { + background: transparent; + padding: 0 12px; + height: 34px; + line-height: 1.5; + margin-top: 0; + min-width: 5em; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 3px; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; +} + +.select2-container--bootstrap .select2-selection--multiple .select2-selection__clear { + margin-top: 6px; +} + +.select2-container--bootstrap .select2-selection--single.input-sm, +.input-group-sm .select2-container--bootstrap .select2-selection--single, +.form-group-sm .select2-container--bootstrap .select2-selection--single { + border-radius: 3px; + font-size: 12px; + height: 30px; + line-height: 1.5; + padding: 5px 22px 5px 10px; +} + +.select2-container--bootstrap .select2-selection--single.input-sm .select2-selection__arrow b, +.input-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b, +.form-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b { + margin-left: -5px; +} + +.select2-container--bootstrap .select2-selection--multiple.input-sm, +.input-group-sm .select2-container--bootstrap .select2-selection--multiple, +.form-group-sm .select2-container--bootstrap .select2-selection--multiple { + min-height: 30px; + border-radius: 3px; +} + +.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__choice, +.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice, +.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice { + font-size: 12px; + line-height: 1.5; + margin: 4px 0 0 5px; + padding: 0 5px; +} + +.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-search--inline .select2-search__field, +.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field, +.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field { + padding: 0 10px; + font-size: 12px; + height: 28px; + line-height: 1.5; +} + +.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__clear, +.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear, +.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear { + margin-top: 5px; +} + +.select2-container--bootstrap .select2-selection--single.input-lg, +.input-group-lg .select2-container--bootstrap .select2-selection--single, +.form-group-lg .select2-container--bootstrap .select2-selection--single { + border-radius: 6px; + font-size: 18px; + height: 46px; + line-height: 1.3333333; + padding: 10px 31px 10px 16px; +} + +.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow, +.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow, +.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow { + width: 5px; +} + +.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow b, +.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b, +.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b { + border-width: 5px 5px 0 5px; + margin-left: -5px; + margin-top: -2.5px; +} + +.select2-container--bootstrap .select2-selection--multiple.input-lg, +.input-group-lg .select2-container--bootstrap .select2-selection--multiple, +.form-group-lg .select2-container--bootstrap .select2-selection--multiple { + min-height: 46px; + border-radius: 6px; +} + +.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__choice, +.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice, +.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice { + font-size: 18px; + line-height: 1.3333333; + border-radius: 4px; + margin: 9px 0 0 8px; + padding: 0 10px; +} + +.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-search--inline .select2-search__field, +.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field, +.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field { + padding: 0 16px; + font-size: 18px; + height: 44px; + line-height: 1.3333333; +} + +.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__clear, +.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear, +.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear { + margin-top: 10px; +} + +.select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #999 transparent; + border-width: 0 5px 5px 5px; +} + +.input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #999 transparent; + border-width: 0 5px 5px 5px; +} + +.select2-container--bootstrap[dir="rtl"] .select2-selection--single { + padding-left: 24px; + padding-right: 12px; +} + +.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 0; + padding-left: 0; + text-align: right; +} + +.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; +} + +.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +/* DataGrid Component */ + +/* Base mobile-first layout: 1 column */ +.bp-datagrid { + display: grid; + grid-gap: 1.5rem; + grid-template-columns: 1fr; +} + +.bp-datagrid-title { + font-size: .625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + line-height: 1rem; + color: #66626c; + margin-bottom: 0.25rem; +} + +/* Breakpoint 1: Small screens (≥768px) — 6 columns */ +@media (min-width: 768px) { + .bp-datagrid { + grid-template-columns: repeat(6, 1fr); + } + + .bp-datagrid-item.size-1 { + grid-column: span 1 / span 1; + } + + .bp-datagrid-item.size-2 { + grid-column: span 2 / span 2; + } + + .bp-datagrid-item.size-3 { + grid-column: span 3 / span 3; + } + + .bp-datagrid-item.size-4 { + grid-column: span 4 / span 4; + } + + .bp-datagrid-item.size-5 { + grid-column: span 5 / span 5; + } + + .bp-datagrid-item.size-6 { + grid-column: span 6 / span 6; + } +} + +/* Breakpoint 2: Large screens (≥1024px) — 12 columns */ +@media (min-width: 1024px) { + .bp-datagrid { + grid-template-columns: repeat(12, 1fr); + } + + .bp-datagrid-item.size-1 { + grid-column: span 1 / span 1; + } + + .bp-datagrid-item.size-2 { + grid-column: span 2 / span 2; + } + + .bp-datagrid-item.size-3 { + grid-column: span 3 / span 3; + } + + .bp-datagrid-item.size-4 { + grid-column: span 4 / span 4; + } + + .bp-datagrid-item.size-5 { + grid-column: span 5 / span 5; + } + + .bp-datagrid-item.size-6 { + grid-column: span 6 / span 6; + } + + .bp-datagrid-item.size-7 { + grid-column: span 7 / span 7; + } + + .bp-datagrid-item.size-8 { + grid-column: span 8 / span 8; + } + + .bp-datagrid-item.size-9 { + grid-column: span 9 / span 9; + } + + .bp-datagrid-item.size-10 { + grid-column: span 10 / span 10; + } + + .bp-datagrid-item.size-11 { + grid-column: span 11 / span 11; + } + + .bp-datagrid-item.size-12 { + grid-column: span 12 / span 12; + } +} diff --git a/src/resources/assets/css/responsive-modal.css b/src/resources/assets/css/responsive-modal.css new file mode 100644 index 0000000000..9605265dae --- /dev/null +++ b/src/resources/assets/css/responsive-modal.css @@ -0,0 +1,39 @@ +/* DataTables Responsive Modal Styling */ +.dtr-bs-modal .modal-body { + padding: 20px; +} + +.dtr-bs-modal .dtr-details-table { + width: 100%; + margin-bottom: 0; +} + +.dtr-bs-modal .dtr-details-table td { + padding: 8px; + vertical-align: top; +} + +.dtr-bs-modal .dtr-details-table tr { + border-bottom: 1px solid #f0f0f0; +} + +.dtr-bs-modal .dtr-details-table tr:last-child { + border-bottom: none; +} + +.dtr-bs-modal .modal-header { + border-bottom: 1px solid #e9ecef; + padding: 15px 20px; +} + +.dtr-bs-modal .modal-footer { + border-top: 1px solid #e9ecef; + padding: 15px 20px; +} + +/* Fix for bullet list appearance */ +.dtr-bs-modal ul { + list-style: none; + padding: 0; + margin: 0; +} diff --git a/src/resources/lang/ar/base.php b/src/resources/lang/ar/base.php index 6d284b6132..3a1b44fa32 100644 --- a/src/resources/lang/ar/base.php +++ b/src/resources/lang/ar/base.php @@ -53,6 +53,24 @@ 'session_expired_error' => 'انتهت صلاحية جلسة العمل الخاصة بك. يرجى تسجيل الدخول مرة أخرى إلى حسابك.', 'welcome' => 'مرحباً!', 'use_sidebar' => 'استخدم الشريط الجانبي على اليسار لإنشاء المحتوى أو تعديله أو حذفه.', + + 'error_page' => [ + 'title' => 'خطأ :error', + 'button' => 'اذهب للصفحة الرئيسية', + 'message_4xx' => 'يرجى العودة أو الرجوع إلى صفحتنا الرئيسية.', + 'message_500' => 'حدث خطأ داخلي في الخادم. إذا استمر الخطأ، يرجى الاتصال بفريق التطوير.', + 'message_503' => 'الخادم محمّل بشكل زائد أو تحت الصيانة. يرجى المحاولة مرة أخرى لاحقاً.', + '400' => 'طلب خاطئ.', + '401' => 'إجراء غير مصرح به.', + '403' => 'ممنوع.', + '404' => 'الصفحة غير موجودة.', + '405' => 'الطريقة غير مسموحة.', + '408' => 'انتهت مهلة الطلب.', + '429' => 'طلبات كثيرة جداً.', + '500' => 'ليس أنت، إنني أنا.', + '503' => 'الخادم تحت الصيانة أو محمّل بشكل زائد.', + ], + 'password_reset' => [ 'greeting' => 'مرحبًا!', 'subject' => 'إعادة تعيين إشعار كلمة المرور', diff --git a/src/resources/lang/bg/base.php b/src/resources/lang/bg/base.php index 1a5546d6c6..15ca01cbc6 100644 --- a/src/resources/lang/bg/base.php +++ b/src/resources/lang/bg/base.php @@ -31,4 +31,21 @@ 'dashboard' => 'Табло', 'handcrafted_by' => 'Ръчна изработка от', 'powered_by' => 'Задвижван от', + + 'error_page' => [ + 'title' => 'Грешка :error', + 'button' => 'Върни ме у дома', + 'message_4xx' => 'Моля върнете се назад или се върнете към нашата начална страница.', + 'message_500' => 'Възникна вътрешна грешка на сървъра. Ако грешката продължава да се появява, моля свържете се с екипа за разработка.', + 'message_503' => 'Сървърът е претоварен или под поддръжка. Моля опитайте отново по-късно.', + '400' => 'Невалидна заявка.', + '401' => 'Неоторизирано действие.', + '403' => 'Забранено.', + '404' => 'Страницата не е намерена.', + '405' => 'Методът не е разрешен.', + '408' => 'Изтекъл време за заявката.', + '429' => 'Твърде много заявки.', + '500' => 'Не сте вие, аз съм проблемът.', + '503' => 'Сървърът е под поддръжка или претоварен.', + ], ]; diff --git a/src/resources/lang/bn/base.php b/src/resources/lang/bn/base.php index 522a4a3b40..b3a6c48fa3 100644 --- a/src/resources/lang/bn/base.php +++ b/src/resources/lang/bn/base.php @@ -54,6 +54,23 @@ 'welcome' => 'স্বাগত!', 'use_sidebar' => 'কন্টেন্ট তৈরি করতে, সম্পাদনা করতে বা মুছতে বাম দিকে সাইডবারটি ব্যবহার করুন', + 'error_page' => [ + 'title' => 'ত্রুটি :error', + 'button' => 'আমাকে বাড়িতে নিয়ে যান', + 'message_4xx' => 'অনুগ্রহ করে ফিরে যান অথবা আমাদের হোমপেজে ফিরে যান।', + 'message_500' => 'একটি অভ্যন্তরীণ সার্ভার ত্রুটি ঘটেছে। যদি ত্রুটি অব্যাহত থাকে তাহলে অনুগ্রহ করে উন্নয়ন দলের সাথে যোগাযোগ করুন।', + 'message_503' => 'সার্ভার অতিরিক্ত লোডে রয়েছে বা রক্ষণাবেক্ষণে রয়েছে। অনুগ্রহ করে পরে আবার চেষ্টা করুন।', + '400' => 'খারাপ অনুরোধ।', + '401' => 'অননুমোদিত কর্ম।', + '403' => 'নিষিদ্ধ।', + '404' => 'পৃষ্ঠা পাওয়া যায়নি।', + '405' => 'পদ্ধতি অনুমোদিত নয়।', + '408' => 'অনুরোধের সময়সীমা।', + '429' => 'অনেক বেশি অনুরোধ।', + '500' => 'এটা আপনার দোষ নয়, এটা আমার।', + '503' => 'সার্ভার রক্ষণাবেক্ষণে বা অতিরিক্ত লোডে রয়েছে।', + ], + 'password_reset' => [ 'greeting' => 'হ্যালো!', 'subject' => 'পাসওয়ার্ড নোটিফিকেশান পুনরায় সেট করুন।', diff --git a/src/resources/lang/cs/base.php b/src/resources/lang/cs/base.php index 959efb5a1c..51bb90bf9d 100644 --- a/src/resources/lang/cs/base.php +++ b/src/resources/lang/cs/base.php @@ -54,6 +54,23 @@ 'welcome' => 'Vítejte!', 'use_sidebar' => 'Použíjte boční menu k vytvoření, editaci nebo smazání záznamů.', + 'error_page' => [ + 'title' => 'Chyba :error', + 'button' => 'Přejít na domovskou stránku', + 'message_4xx' => 'Prosím vraťte se zpět nebo přejděte na naši domovskou stránku.', + 'message_500' => 'Došlo k vnitřní chybě serveru. Pokud chyba přetrvává, kontaktujte vývojářský tým.', + 'message_503' => 'Server je přetížený nebo v údržbě. Zkuste to prosím později.', + '400' => 'Neplatný požadavek.', + '401' => 'Neautorizovaná akce.', + '403' => 'Zakázáno.', + '404' => 'Stránka nenalezena.', + '405' => 'Metoda není povolena.', + '408' => 'Časový limit požadavku.', + '429' => 'Příliš mnoho požadavků.', + '500' => 'Není to váš problém, je to můj.', + '503' => 'Server je v údržbě nebo přetížený.', + ], + 'password_reset' => [ 'greeting' => 'Vítejte!', 'subject' => 'Žádost o nové heslo', diff --git a/src/resources/lang/da-DK/base.php b/src/resources/lang/da-DK/base.php index f36c95314f..32903e98ce 100644 --- a/src/resources/lang/da-DK/base.php +++ b/src/resources/lang/da-DK/base.php @@ -31,4 +31,21 @@ 'dashboard' => 'Forside', 'handcrafted_by' => 'håndlavet af', 'powered_by' => 'bygget på', + + 'error_page' => [ + 'title' => 'Fejl :error', + 'button' => 'Tag mig hjem', + 'message_4xx' => 'Venligst gå tilbage eller vend tilbage til vores hjemmeside.', + 'message_500' => 'Der opstod en intern serverfejl. Hvis fejlen fortsætter, kontakt venligst udviklingsteamet.', + 'message_503' => 'Serveren er overbelastet eller under vedligeholdelse. Prøv venligst igen senere.', + '400' => 'Dårlig anmodning.', + '401' => 'Uautoriseret handling.', + '403' => 'Forbudt.', + '404' => 'Side ikke fundet.', + '405' => 'Metode ikke tilladt.', + '408' => 'Anmodning timeout.', + '429' => 'For mange anmodninger.', + '500' => 'Det er ikke dig, det er mig.', + '503' => 'Server er under vedligeholdelse eller overbelastet.', + ], ]; diff --git a/src/resources/lang/da_DK/base.php b/src/resources/lang/da_DK/base.php index 5168ebf8d2..c1137ca136 100644 --- a/src/resources/lang/da_DK/base.php +++ b/src/resources/lang/da_DK/base.php @@ -42,4 +42,21 @@ 'dashboard' => 'Forside', 'handcrafted_by' => 'håndlavet af', 'powered_by' => 'bygget på', + + 'error_page' => [ + 'title' => 'Fejl :error', + 'button' => 'Tag mig hjem', + 'message_4xx' => 'Venligst gå tilbage eller vend tilbage til vores hjemmeside.', + 'message_500' => 'Der opstod en intern serverfejl. Hvis fejlen fortsætter, kontakt venligst udviklingsteamet.', + 'message_503' => 'Serveren er overbelastet eller under vedligeholdelse. Prøv venligst igen senere.', + '400' => 'Dårlig anmodning.', + '401' => 'Uautoriseret handling.', + '403' => 'Forbudt.', + '404' => 'Side ikke fundet.', + '405' => 'Metode ikke tilladt.', + '408' => 'Anmodning timeout.', + '429' => 'For mange anmodninger.', + '500' => 'Det er ikke dig, det er mig.', + '503' => 'Server er under vedligeholdelse eller overbelastet.', + ], ]; diff --git a/src/resources/lang/de/base.php b/src/resources/lang/de/base.php index 476a783d07..7d37bf25a8 100644 --- a/src/resources/lang/de/base.php +++ b/src/resources/lang/de/base.php @@ -52,6 +52,23 @@ 'welcome' => 'Willkommen!', 'use_sidebar' => 'Nutze die Seitenleiste links, um Inhalte zu erstellen oder zu editieren.', + 'error_page' => [ + 'title' => 'Fehler :error', + 'button' => 'Zur Startseite', + 'message_4xx' => 'Bitte gehen Sie zurück oder kehren Sie zu unserer Startseite zurück.', + 'message_500' => 'Ein interner Serverfehler ist aufgetreten. Wenn der Fehler weiterhin besteht, wenden Sie sich bitte an das Entwicklungsteam.', + 'message_503' => 'Der Server ist überlastet oder wird gewartet. Bitte versuchen Sie es später erneut.', + '400' => 'Ungültige Anfrage.', + '401' => 'Unbefugte Aktion.', + '403' => 'Verboten.', + '404' => 'Seite nicht gefunden.', + '405' => 'Methode nicht erlaubt.', + '408' => 'Anfrage-Timeout.', + '429' => 'Zu viele Anfragen.', + '500' => 'Es liegt nicht an dir, es liegt an mir.', + '503' => 'Server ist unter Wartung oder überlastet.', + ], + 'password_reset' => [ 'greeting' => 'Hallo!', 'subject' => 'Passwort zurücksetzen Information', diff --git a/src/resources/lang/el/base.php b/src/resources/lang/el/base.php index abac2bf41a..146fb32767 100644 --- a/src/resources/lang/el/base.php +++ b/src/resources/lang/el/base.php @@ -48,4 +48,21 @@ 'unknown_error' => 'Προέκυψε κάποιο σφάλμα. Παρακαλώ προσπαθήστε ξανά.', 'error_saving' => 'Σφάλμα κατά την αποθήκευση. Παρακαλώ προσπαθήστε ξανά.', 'session_expired_error' => 'Η συνεδρία σας έχει λήξει. Παρακαλούμε συνδεθείτε ξανά στο λογαριασμό σας.', + + 'error_page' => [ + 'title' => 'Σφάλμα :error', + 'button' => 'Πήγαινέ με στην αρχική', + 'message_4xx' => 'Παρακαλούμε επιστρέψτε πίσω ή επιστρέψτε στην αρχική μας σελίδα.', + 'message_500' => 'Παρουσιάστηκε εσωτερικό σφάλμα διακομιστή. Εάν το σφάλμα παραμένει, επικοινωνήστε με την ομάδα ανάπτυξης.', + 'message_503' => 'Ο διακομιστής είναι υπερφορτωμένος ή σε συντήρηση. Παρακαλούμε δοκιμάστε ξανά αργότερα.', + '400' => 'Κακό αίτημα.', + '401' => 'Μη εξουσιοδοτημένη ενέργεια.', + '403' => 'Απαγορευμένο.', + '404' => 'Η σελίδα δεν βρέθηκε.', + '405' => 'Η μέθοδος δεν επιτρέπεται.', + '408' => 'Λήξη χρονικού ορίου αιτήματος.', + '429' => 'Πάρα πολλά αιτήματα.', + '500' => 'Δεν είσαι εσύ, είμαι εγώ.', + '503' => 'Ο διακομιστής είναι σε συντήρηση ή υπερφορτωμένος.', + ], ]; diff --git a/src/resources/lang/en/base.php b/src/resources/lang/en/base.php index 54f80f0e2b..f792f2cacc 100644 --- a/src/resources/lang/en/base.php +++ b/src/resources/lang/en/base.php @@ -70,6 +70,8 @@ '408' => 'Request timeout.', '429' => 'Too many requests.', '500' => 'It\'s not you, it\'s me.', + '503' => 'Server is under maintenance or overloaded.', + ], 'password_reset' => [ diff --git a/src/resources/lang/fa/base.php b/src/resources/lang/fa/base.php index ae37b3632d..9b6e6236a8 100644 --- a/src/resources/lang/fa/base.php +++ b/src/resources/lang/fa/base.php @@ -54,6 +54,23 @@ 'welcome' => 'خوش آمدید!', 'use_sidebar' => 'برای ایجاد ، ویرایش یا حذف محتوا از نوار کناری در سمت چپ استفاده کنید.', + 'error_page' => [ + 'title' => 'خطای :error', + 'button' => 'بازگشت به خانه', + 'message_4xx' => 'لطفا بازگردید یا به صفحه اصلی ما برگردید.', + 'message_500' => 'خطای داخلی سرور رخ داده است. اگر خطا ادامه پیدا کند، لطفا با تیم توسعه تماس بگیرید.', + 'message_503' => 'سرور بارگذاری بیش از حد دارد یا در حال تعمیر است. لطفا بعدا امتحان کنید.', + '400' => 'درخواست بد.', + '401' => 'عمل غیرمجاز.', + '403' => 'ممنوع.', + '404' => 'صفحه یافت نشد.', + '405' => 'روش مجاز نیست.', + '408' => 'زمان درخواست تمام شد.', + '429' => 'درخواست های زیادی.', + '500' => 'شما نیستید، من هستم.', + '503' => 'سرور در حال تعمیر یا بارگذاری بیش از حد است.', + ], + 'password_reset' => [ 'greeting' => 'سلام!', 'subject' => 'اعلان بازنشانی گذرواژه', diff --git a/src/resources/lang/fr-CA/base.php b/src/resources/lang/fr-CA/base.php index 5be5ab248f..7949e80f4e 100644 --- a/src/resources/lang/fr-CA/base.php +++ b/src/resources/lang/fr-CA/base.php @@ -59,4 +59,21 @@ 'unknown_error' => 'Un erreur s’est produite. Veuillez réessayer.', 'error_saving' => 'Erreur lors de l’enregistrement. Veuillez réessayer.', 'session_expired_error' => 'Votre session a expiré. Veuillez vous reconnecter à votre compte.', + + 'error_page' => [ + 'title' => 'Erreur :error', + 'button' => 'Retour à l\'accueil', + 'message_4xx' => 'Veuillez revenir en arrière ou retourner à notre page d\'accueil.', + 'message_500' => 'Une erreur interne du serveur s\'est produite. Si l\'erreur persiste, veuillez contacter l\'équipe de développement.', + 'message_503' => 'Le serveur est surchargé ou en maintenance. Veuillez réessayer plus tard.', + '400' => 'Demande incorrecte.', + '401' => 'Action non autorisée.', + '403' => 'Interdit.', + '404' => 'Page non trouvée.', + '405' => 'Méthode non autorisée.', + '408' => 'Délai d\'attente de la demande.', + '429' => 'Trop de demandes.', + '500' => 'Ce n\'est pas vous, c\'est moi.', + '503' => 'Serveur en maintenance ou surchargé.', + ], ]; diff --git a/src/resources/lang/fr/base.php b/src/resources/lang/fr/base.php index 79380c6fcb..bfdb38e99f 100644 --- a/src/resources/lang/fr/base.php +++ b/src/resources/lang/fr/base.php @@ -52,6 +52,23 @@ 'welcome' => 'Bienvenue!', 'use_sidebar' => 'Utilisez la barre latérale pour ajouter, modifier ou supprimer du contenu.', + 'error_page' => [ + 'title' => 'Erreur :error', + 'button' => 'Retour à l\'accueil', + 'message_4xx' => 'Veuillez revenir en arrière ou retourner à notre page d\'accueil.', + 'message_500' => 'Une erreur interne du serveur s\'est produite. Si l\'erreur persiste, veuillez contacter l\'équipe de développement.', + 'message_503' => 'Le serveur est surchargé ou en maintenance. Veuillez réessayer plus tard.', + '400' => 'Demande incorrecte.', + '401' => 'Action non autorisée.', + '403' => 'Interdit.', + '404' => 'Page non trouvée.', + '405' => 'Méthode non autorisée.', + '408' => 'Délai d\'attente de la demande.', + '429' => 'Trop de demandes.', + '500' => 'Ce n\'est pas vous, c\'est moi.', + '503' => 'Serveur en maintenance ou surchargé.', + ], + 'password_reset' => [ 'greeting' => 'Bonjour!', 'subject' => 'Réinitialisation du mot de passe', diff --git a/src/resources/lang/fr_CA/base.php b/src/resources/lang/fr_CA/base.php index 5be5ab248f..7949e80f4e 100644 --- a/src/resources/lang/fr_CA/base.php +++ b/src/resources/lang/fr_CA/base.php @@ -59,4 +59,21 @@ 'unknown_error' => 'Un erreur s’est produite. Veuillez réessayer.', 'error_saving' => 'Erreur lors de l’enregistrement. Veuillez réessayer.', 'session_expired_error' => 'Votre session a expiré. Veuillez vous reconnecter à votre compte.', + + 'error_page' => [ + 'title' => 'Erreur :error', + 'button' => 'Retour à l\'accueil', + 'message_4xx' => 'Veuillez revenir en arrière ou retourner à notre page d\'accueil.', + 'message_500' => 'Une erreur interne du serveur s\'est produite. Si l\'erreur persiste, veuillez contacter l\'équipe de développement.', + 'message_503' => 'Le serveur est surchargé ou en maintenance. Veuillez réessayer plus tard.', + '400' => 'Demande incorrecte.', + '401' => 'Action non autorisée.', + '403' => 'Interdit.', + '404' => 'Page non trouvée.', + '405' => 'Méthode non autorisée.', + '408' => 'Délai d\'attente de la demande.', + '429' => 'Trop de demandes.', + '500' => 'Ce n\'est pas vous, c\'est moi.', + '503' => 'Serveur en maintenance ou surchargé.', + ], ]; diff --git a/src/resources/lang/hu/base.php b/src/resources/lang/hu/base.php index 0bf77e56d9..2d8cf6afd6 100644 --- a/src/resources/lang/hu/base.php +++ b/src/resources/lang/hu/base.php @@ -54,6 +54,23 @@ 'welcome' => 'Üdvözöllek!', 'use_sidebar' => 'Használd a bal oldali sávot tartalom létrehozásához, szerkesztéséhez, vagy törléséhez.', + 'error_page' => [ + 'title' => 'Hiba :error', + 'button' => 'Vissza a kezdőlapra', + 'message_4xx' => 'Kérjük menjen vissza vagy térjen vissza a kezdőlapunkra.', + 'message_500' => 'Belső szerverhiba történt. Ha a hiba továbbra is fennáll, forduljon a fejlesztői csapathoz.', + 'message_503' => 'A szerver túlterhelt vagy karbantartás alatt áll. Kérjük, próbálja meg később.', + '400' => 'Hibás kérés.', + '401' => 'Nem engedélyezett művelet.', + '403' => 'Tiltott.', + '404' => 'Az oldal nem található.', + '405' => 'A módszer nem engedélyezett.', + '408' => 'Kérés időtúllépése.', + '429' => 'Túl sok kérés.', + '500' => 'Nem te vagy a hibás, én vagyok.', + '503' => 'A szerver karbantartás alatt vagy túlterhelt.', + ], + 'password_reset' => [ 'greeting' => 'Szia!', 'subject' => 'Jelszó visszaállítás', diff --git a/src/resources/lang/id/base.php b/src/resources/lang/id/base.php index 192c803272..b74c8d193b 100644 --- a/src/resources/lang/id/base.php +++ b/src/resources/lang/id/base.php @@ -54,6 +54,23 @@ 'welcome' => 'Selamat datang!', 'use_sidebar' => 'Gunakan menu kiri untuk membuat, mengedit, atau menghapus konten.', + 'error_page' => [ + 'title' => 'Error :error', + 'button' => 'Bawa saya pulang', + 'message_4xx' => 'Silakan kembali atau kembali ke beranda kami.', + 'message_500' => 'Terjadi kesalahan server internal. Jika kesalahan berlanjut, harap hubungi tim pengembangan.', + 'message_503' => 'Server kelebihan beban atau sedang dalam pemeliharaan. Silakan coba lagi nanti.', + '400' => 'Permintaan buruk.', + '401' => 'Tindakan tidak sah.', + '403' => 'Terlarang.', + '404' => 'Halaman tidak ditemukan.', + '405' => 'Metode tidak diizinkan.', + '408' => 'Waktu habis permintaan.', + '429' => 'Terlalu banyak permintaan.', + '500' => 'Bukan kamu, itu aku.', + '503' => 'Server sedang dalam pemeliharaan atau kelebihan beban.', + ], + 'password_reset' => [ 'greeting' => 'Halo!', 'subject' => 'Setel Ulang Pemberitahuan Kata Sandi', diff --git a/src/resources/lang/it/base.php b/src/resources/lang/it/base.php index 633e8d6b4b..5bf69cd291 100644 --- a/src/resources/lang/it/base.php +++ b/src/resources/lang/it/base.php @@ -56,6 +56,23 @@ 'welcome' => 'Benvenuto!', 'use_sidebar' => 'Utilizza la barra laterale per creare, modificare od eliminare contenuti.', + 'error_page' => [ + 'title' => 'Errore :error', + 'button' => 'Portami a casa', + 'message_4xx' => 'Si prega di tornare indietro o tornare alla nostra homepage.', + 'message_500' => 'Si è verificato un errore interno del server. Se l\'errore persiste, contattare il team di sviluppo.', + 'message_503' => 'Il server è sovraccarico o in manutenzione. Riprova più tardi.', + '400' => 'Richiesta errata.', + '401' => 'Azione non autorizzata.', + '403' => 'Vietato.', + '404' => 'Pagina non trovata.', + '405' => 'Metodo non consentito.', + '408' => 'Timeout della richiesta.', + '429' => 'Troppe richieste.', + '500' => 'Non sei tu, sono io.', + '503' => 'Server in manutenzione o sovraccarico.', + ], + 'password_reset' => [ 'greeting' => 'Ciao!', 'subject' => 'Notifica di reset della password', diff --git a/src/resources/lang/ja/base.php b/src/resources/lang/ja/base.php index f689864a34..0613d97abf 100644 --- a/src/resources/lang/ja/base.php +++ b/src/resources/lang/ja/base.php @@ -54,6 +54,23 @@ 'welcome' => 'ようこそ!', 'use_sidebar' => '左側のサイドバーを使用してコンテンツを作成や編集、削除しましょう。', + 'error_page' => [ + 'title' => 'エラー :error', + 'button' => 'ホームへ戻る', + 'message_4xx' => '戻るホームページに戻ってください。', + 'message_500' => '内部サーバーエラーが発生しました。エラーが続く場合は、開発チームにお問い合わせください。', + 'message_503' => 'サーバーが過負荷状態またはメンテナンス中です。後でもう一度お試しください。', + '400' => '不正なリクエストです。', + '401' => '許可されていない操作です。', + '403' => 'アクセスが禁止されています。', + '404' => 'ページが見つかりません。', + '405' => '許可されていないメソッドです。', + '408' => 'リクエストタイムアウトです。', + '429' => 'リクエスト数が多すぎます。', + '500' => 'あなたの問題ではありません、私たちの問題です。', + '503' => 'サーバーはメンテナンス中または過負荷状態です。', + ], + 'password_reset' => [ 'greeting' => 'こんにちは!', 'subject' => 'パスワードリセットのお知らせ', diff --git a/src/resources/lang/lv/base.php b/src/resources/lang/lv/base.php index 16e7129de7..c731a8e375 100644 --- a/src/resources/lang/lv/base.php +++ b/src/resources/lang/lv/base.php @@ -56,6 +56,23 @@ 'welcome' => 'Laipni lūgti!', 'use_sidebar' => 'Izmantojiet sānjoslu pa kreisi, lai izveidotu, rediģētu vai izdzēstu saturu.', + 'error_page' => [ + 'title' => 'Kļūda :error', + 'button' => 'Aizvest mājās', + 'message_4xx' => 'Lūdzu atgriezieties atpakaļ vai dodieties uz mūsu sākumlapu.', + 'message_500' => 'Radusies servera iekšējā kļūda. Ja kļūda turpinās, sazinieties ar izstrādes komandu.', + 'message_503' => 'Serveris ir pārslogots vai notiek apkope. Lūdzu, mēģiniet vēlāk.', + '400' => 'Slikts pieprasījums.', + '401' => 'Neatļauta darbība.', + '403' => 'Aizliegts.', + '404' => 'Lapa nav atrasta.', + '405' => 'Metode nav atļauta.', + '408' => 'Pieprasījuma noilgums.', + '429' => 'Pārāk daudz pieprasījumu.', + '500' => 'Tā nav tava vaina, tā ir mana.', + '503' => 'Servers ir apkopē vai pārslogots.', + ], + 'password_reset' => [ 'greeting' => 'Labdien!', 'subject' => 'Paroles atjaunošanas paziņojums', diff --git a/src/resources/lang/my/base.php b/src/resources/lang/my/base.php index d9ca8a0a15..88f75d2f4f 100644 --- a/src/resources/lang/my/base.php +++ b/src/resources/lang/my/base.php @@ -54,6 +54,23 @@ 'welcome' => 'ကြိုဆိုပါတယ်', 'use_sidebar' => 'အကြောင်းအရာဖန်တီးရန်၊ တည်းဖြတ်ရန်နှင့် ဖျက်ရန် ဘယ်ဘက်ဘေးဘားကို အသုံးပြုပါ။', + 'error_page' => [ + 'title' => 'အမှား :error', + 'button' => 'အိမ်ပြန်ယူဆောင်ပါ။', + 'message_4xx' => 'ကျေးဇူးပြု၍ ပြန်သွားပါ သို့မဟုတ် ကျွန်ုပ်တို့၏ ပင်မစာမျက်နှာသို့ ပြန်သွားပါ။', + 'message_500' => 'အတွင်းပိုင်း ဆာဗာအမှားတစ်ခု ဖြစ်ပွားခဲ့သည်။ အမှားသည် ဆက်လက်ဖြစ်ပေါ်နေပါက၊ ကျေးဇူးပြု၍ ဖွံ့ဖြိုးတိုးတက်ရေးအဖွဲ့ကို ဆက်သွယ်ပါ။', + 'message_503' => 'ဆာဗာသည် အလွန်ပြေးနေသည် သို့မဟုတ် ပြုပြင်နေသည်။ ကျေးဇူးပြု၍ နောက်မှထပ်စမ်းပါ။', + '400' => 'မကောင်းသောတောင်းဆိုချက်။', + '401' => 'ခွင့်မပြုထားသောလုပ်ရပ်။', + '403' => 'တားမြစ်ထားသည်။', + '404' => 'စာမျက်နှာကို ရှာမတွေ့ပါ။', + '405' => 'နည်းလမ်းကို ခွင့်မပြုပါ။', + '408' => 'တောင်းဆိုချက် အချိန်ကုန်သွားသည်။', + '429' => 'တောင်းဆိုချက်များ အလွန်များလွန်းသည်။', + '500' => 'သင်မဟုတ်ပါ၊ ကျွန်တော်ပါ။', + '503' => 'ဆာဗာသည် ပြုပြင်နေသည် သို့မဟုတ် အလွန်ပြေးနေသည်။', + ], + 'password_reset' => [ 'greeting' => 'မင်္ဂလာပါ', 'subject' => 'စကားဝှက် ပြန်လည်သတ်မှတ်ခြင်း အသိပေးချက်', @@ -64,7 +81,7 @@ ], 'step' => 'အဆင့်', - 'confirm_email' => 'သင့်အီးမေးလ်လိပ်စာကို အတည်ပြုပါ။', + 'confirm_email' => 'သင့်အီးမေးလိပ်စာကို အတည်ပြုပါ။', 'choose_new_password' => 'စကားဝှက်အသစ်ကို ရွေးချယ်ခြင်း။', 'confirm_new_password' => 'စကားဝှက်အသစ်ကို အတည်ပြုပါ။', 'throttled' => 'သင့် စကားဝှက်သတ်မှတ်ချက်ကို မကြာခင်ကတောင်းထားပါသည်၊ အီးမေးလ် ကိုစစ်ပါ၊ အီးမေးလ်မရပါက နောက်မှ ပြန်စမ်းကြည့်ပါ။', diff --git a/src/resources/lang/nl/base.php b/src/resources/lang/nl/base.php index 8f940272c4..d398bb55d8 100644 --- a/src/resources/lang/nl/base.php +++ b/src/resources/lang/nl/base.php @@ -54,6 +54,23 @@ 'welcome' => 'Welkom!', 'use_sidebar' => 'Gebruik de menubalk links om inhoud te maken, bewerken of verwijderen.', + 'error_page' => [ + 'title' => 'Fout :error', + 'button' => 'Breng me naar huis', + 'message_4xx' => 'Ga alstublieft terug of keer terug naar onze homepage.', + 'message_500' => 'Er is een interne serverfout opgetreden. Als de fout aanhoudt, neem dan contact op met het ontwikkelingsteam.', + 'message_503' => 'De server is overbelast of in onderhoud. Probeer het later opnieuw.', + '400' => 'Verkeerd verzoek.', + '401' => 'Ongeautoriseerde actie.', + '403' => 'Verboden.', + '404' => 'Pagina niet gevonden.', + '405' => 'Methode niet toegestaan.', + '408' => 'Verzoek timeout.', + '429' => 'Te veel verzoeken.', + '500' => 'Het ligt niet aan jou, het ligt aan mij.', + '503' => 'Server is in onderhoud of overbelast.', + ], + 'password_reset' => [ 'greeting' => 'Hallo!', 'subject' => 'Wachtwoordherstel notificatie', diff --git a/src/resources/lang/pt-BR/base.php b/src/resources/lang/pt-BR/base.php index 8a51c60ddb..edd4a0fca8 100644 --- a/src/resources/lang/pt-BR/base.php +++ b/src/resources/lang/pt-BR/base.php @@ -62,6 +62,24 @@ 'session_expired_error' => 'Sua sessão expirou. Faça login novamente em sua conta.', 'welcome' => 'Bem vindo!', 'use_sidebar' => 'Use a barra de menu à esquerda para criar, editar ou excluir conteúdo.', + + 'error_page' => [ + 'title' => 'Erro :error', + 'button' => 'Levar-me para casa', + 'message_4xx' => 'Por favor volte ou retorne para nossa página inicial.', + 'message_500' => 'Ocorreu um erro interno do servidor. Se o erro persistir, por favor contacte a equipe de desenvolvimento.', + 'message_503' => 'O servidor está sobrecarregado ou em manutenção. Por favor, tente novamente mais tarde.', + '400' => 'Solicitação inválida.', + '401' => 'Ação não autorizada.', + '403' => 'Proibido.', + '404' => 'Página não encontrada.', + '405' => 'Método não permitido.', + '408' => 'Tempo limite da solicitação.', + '429' => 'Muitas solicitações.', + '500' => 'Não é você, sou eu.', + '503' => 'Servidor em manutenção ou sobrecarregado.', + ], + 'password_reset' => [ 'greeting' => 'Olá!', 'subject' => 'Notificação de redefinição de senha', diff --git a/src/resources/lang/pt/base.php b/src/resources/lang/pt/base.php index 01fc663c99..567b87b300 100644 --- a/src/resources/lang/pt/base.php +++ b/src/resources/lang/pt/base.php @@ -56,9 +56,20 @@ 'error_page' => [ 'title' => 'Erro :error', - 'button' => 'Voltar ao início', + 'button' => 'Voltar à página inicial', + 'message_4xx' => 'Por favor volte atrás ou regresse à nossa página inicial.', + 'message_500' => 'Ocorreu um erro interno do servidor. Se o erro persistir, por favor contacte a equipa de desenvolvimento.', + 'message_503' => 'O servidor está sobrecarregado ou em manutenção. Por favor, tente novamente mais tarde.', + '400' => 'Pedido inválido.', + '401' => 'Ação não autorizada.', + '403' => 'Acesso proibido.', + '404' => 'Página não encontrada.', + '405' => 'Método não permitido.', + '408' => 'Tempo limite do pedido esgotado.', + '429' => 'Demasiados pedidos.', + '500' => 'Não é você, sou eu.', + '503' => 'Servidor em manutenção ou sobrecarregado.', ], - 'password_reset' => [ 'greeting' => 'Olá!', 'subject' => 'Notificação de reposição de password.', diff --git a/src/resources/lang/pt_br/base.php b/src/resources/lang/pt_br/base.php index 8a51c60ddb..edd4a0fca8 100644 --- a/src/resources/lang/pt_br/base.php +++ b/src/resources/lang/pt_br/base.php @@ -62,6 +62,24 @@ 'session_expired_error' => 'Sua sessão expirou. Faça login novamente em sua conta.', 'welcome' => 'Bem vindo!', 'use_sidebar' => 'Use a barra de menu à esquerda para criar, editar ou excluir conteúdo.', + + 'error_page' => [ + 'title' => 'Erro :error', + 'button' => 'Levar-me para casa', + 'message_4xx' => 'Por favor volte ou retorne para nossa página inicial.', + 'message_500' => 'Ocorreu um erro interno do servidor. Se o erro persistir, por favor contacte a equipe de desenvolvimento.', + 'message_503' => 'O servidor está sobrecarregado ou em manutenção. Por favor, tente novamente mais tarde.', + '400' => 'Solicitação inválida.', + '401' => 'Ação não autorizada.', + '403' => 'Proibido.', + '404' => 'Página não encontrada.', + '405' => 'Método não permitido.', + '408' => 'Tempo limite da solicitação.', + '429' => 'Muitas solicitações.', + '500' => 'Não é você, sou eu.', + '503' => 'Servidor em manutenção ou sobrecarregado.', + ], + 'password_reset' => [ 'greeting' => 'Olá!', 'subject' => 'Notificação de redefinição de senha', diff --git a/src/resources/lang/ro/base.php b/src/resources/lang/ro/base.php index 21dcc194fb..a8f8a03f46 100644 --- a/src/resources/lang/ro/base.php +++ b/src/resources/lang/ro/base.php @@ -50,6 +50,23 @@ 'error_saving' => 'Eroare în timpul salvării. Vă rugăm să încercați din nou.', 'session_expired_error' => 'Sesiunea dumneavoastră a expirat. Vă rugăm să vă conectați din nou la contul dvs.', + 'error_page' => [ + 'title' => 'Eroare :error', + 'button' => 'Du-mă acasă', + 'message_4xx' => 'Vă rugăm să vă întoarceți sau să reveniți la pagina noastră principală.', + 'message_500' => 'A apărut o eroare internă de server. Dacă eroarea persistă, vă rugăm să contactați echipa de dezvoltare.', + 'message_503' => 'Serverul este supraîncărcat sau în întreținere. Vă rugăm să încercați din nou mai târziu.', + '400' => 'Cerere greșită.', + '401' => 'Acțiune neautorizată.', + '403' => 'Interzis.', + '404' => 'Pagina nu a fost găsită.', + '405' => 'Metodă nepermisă.', + '408' => 'Timeout cerere.', + '429' => 'Prea multe cereri.', + '500' => 'Nu ești tu, sunt eu.', + '503' => 'Serverul este în întreținere sau supraîncărcat.', + ], + 'password_reset' => [ 'greeting' => 'Salutare!', 'subject' => 'Resetarea parolei dvs', diff --git a/src/resources/lang/sr/base.php b/src/resources/lang/sr/base.php index 5a0224954d..7d76dfeeb5 100644 --- a/src/resources/lang/sr/base.php +++ b/src/resources/lang/sr/base.php @@ -54,6 +54,23 @@ 'welcome' => 'Dobro došli!', 'use_sidebar' => 'Upotrebite bočni meni na levoj strani da kreirate, izmenite ili obrišete sadržaj.', + 'error_page' => [ + 'title' => 'Greška :error', + 'button' => 'Odvedite me kući', + 'message_4xx' => 'Molimo idite nazad ili se vratite na našu početnu stranicu.', + 'message_500' => 'Došlo je do unutrašnje greške servera. Ako greška nastavi da se javlja, kontaktirajte tim za razvoj.', + 'message_503' => 'Server je preopterećen ili je u održavanju. Molimo pokušajte ponovo kasnije.', + '400' => 'Loš zahtev.', + '401' => 'Neovlašćena akcija.', + '403' => 'Zabranjeno.', + '404' => 'Stranica nije pronađena.', + '405' => 'Metoda nije dozvoljena.', + '408' => 'Vreme zahteva je isteklo.', + '429' => 'Previše zahteva.', + '500' => 'Nije do vas, do mene je.', + '503' => 'Server je u održavanju ili preopterećen.', + ], + 'password_reset' => [ 'greeting' => 'Zdravo!', 'subject' => 'Obaveštenje za resetovanje šifre', diff --git a/src/resources/lang/th/base.php b/src/resources/lang/th/base.php index 4177f61948..d28389cd69 100644 --- a/src/resources/lang/th/base.php +++ b/src/resources/lang/th/base.php @@ -54,6 +54,23 @@ 'welcome' => 'ยินดีต้อนรับ!', 'use_sidebar' => 'เลือกเมนูด้านซ้ายเพื่อสร้าง แก้ไข หรือลบเนื้อหา', + 'error_page' => [ + 'title' => 'ข้อผิดพลาด :error', + 'button' => 'กลับไปหน้าหลัก', + 'message_4xx' => 'โปรดกลับไปด้านหลังหรือกลับไปที่หน้าแรกของเรา', + 'message_500' => 'เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์ หากข้อผิดพลาดยังคงมีอยู่ โปรดติดต่อทีมพัฒนา', + 'message_503' => 'เซิร์ฟเวอร์มีโหลดเกินกำลังหรือกำลังปรับปรุง โปรดลองใหม่ภายหลัง', + '400' => 'คำขอไม่ถูกต้อง', + '401' => 'การดำเนินการไม่ได้รับอนุญาต', + '403' => 'ไม่อนุญาต', + '404' => 'ไม่พบหน้า', + '405' => 'ไม่อนุญาตให้ใช้วิธีนี้', + '408' => 'หมดเวลาคำขอ', + '429' => 'คำขอมากเกินไป', + '500' => 'ไม่ใช่ความผิดของคุณ เป็นความผิดของเรา', + '503' => 'เซิร์ฟเวอร์กำลังปรับปรุงหรือโหลดเกิน', + ], + 'password_reset' => [ 'greeting' => 'สวัสดี', 'subject' => 'แจ้งเตือนการรีเซ็ตรหัสผ่าน', diff --git a/src/resources/lang/vi/base.php b/src/resources/lang/vi/base.php index aa5d7244fb..0fdd800ca1 100644 --- a/src/resources/lang/vi/base.php +++ b/src/resources/lang/vi/base.php @@ -56,7 +56,7 @@ 'welcome' => 'Xin chào!', 'use_sidebar' => 'Sử dụng các chức năng ở thanh bên để tạo, sửa hoặc xoá các nội dung.', - 'error' => [ + 'error_page' => [ 'title' => 'Lỗi :error', 'button' => 'Đưa tôi về trang chủ', 'message_4xx' => 'Vui lòng quay lại hoặc trở về trang chủ của chúng tôi.', @@ -70,6 +70,7 @@ '408' => 'Hết thời gian yêu cầu.', '429' => 'Quá nhiều yêu cầu.', '500' => 'Không phải lỗi của bạn, mà là của tôi.', + '503' => 'Máy chủ đang bảo trì hoặc quá tải.', ], 'password_reset' => [ diff --git a/src/resources/lang/zh-Hant/base.php b/src/resources/lang/zh-Hant/base.php index c93d4abbd9..5e95169278 100644 --- a/src/resources/lang/zh-Hant/base.php +++ b/src/resources/lang/zh-Hant/base.php @@ -54,6 +54,23 @@ 'welcome' => '歡迎使用!', 'use_sidebar' => '使用左方的側邊欄以新增、編輯或刪除內容。', + 'error_page' => [ + 'title' => '錯誤 :error', + 'button' => '帶我回家', + 'message_4xx' => '請返回或回到我們的首頁。', + 'message_500' => '發生了內部伺服器錯誤。如果錯誤持續存在,請聯繫開發團隊。', + 'message_503' => '伺服器過載或正在維護中。請稍後再試。', + '400' => '錯誤請求。', + '401' => '未授權操作。', + '403' => '禁止存取。', + '404' => '頁面未找到。', + '405' => '不允許的方法。', + '408' => '請求逾時。', + '429' => '請求過多。', + '500' => '不是你的問題,是我的問題。', + '503' => '伺服器正在維護或過載。', + ], + 'password_reset' => [ 'greeting' => '您好!', 'subject' => '重置密碼通知', diff --git a/src/resources/lang/zh-cn/base.php b/src/resources/lang/zh-cn/base.php index 954ad3334b..fa1a4f5cfb 100644 --- a/src/resources/lang/zh-cn/base.php +++ b/src/resources/lang/zh-cn/base.php @@ -54,6 +54,23 @@ 'welcome' => '欢迎!', 'use_sidebar' => '使用左侧边栏创建、编辑或删除内容。', + 'error_page' => [ + 'title' => '错误 :error', + 'button' => '返回首页', + 'message_4xx' => '请返回或回到我们的首页。', + 'message_500' => '发生了内部服务器错误。如果错误持续存在,请联系开发团队。', + 'message_503' => '服务器过载或正在维护中。请稍后再试。', + '400' => '错误请求。', + '401' => '未授权操作。', + '403' => '禁止访问。', + '404' => '页面未找到。', + '405' => '不允许的方法。', + '408' => '请求超时。', + '429' => '请求过多。', + '500' => '不是你的问题,是我的问题。', + '503' => '服务器正在维护或过载。', + ], + 'password_reset' => [ 'greeting' => '您好!', 'subject' => '重置密码通知', diff --git a/src/resources/views/crud/buttons/delete.blade.php b/src/resources/views/crud/buttons/delete.blade.php index 8c0e3b46d8..eda1abfb0a 100644 --- a/src/resources/views/crud/buttons/delete.blade.php +++ b/src/resources/views/crud/buttons/delete.blade.php @@ -1,5 +1,22 @@ + +@php + $redirectUrl = $crud->getOperationSetting('deleteButtonRedirect'); + if($redirectUrl && $redirectUrl instanceof \Closure){ + $redirectUrl = $redirectUrl(); + } + $redirectUrl = filter_var($redirectUrl, FILTER_VALIDATE_URL) ? $redirectUrl : null; +@endphp + @if ($crud->hasAccess('delete', $entry)) - + {{ trans('backpack::crud.delete') }} @endif @@ -11,103 +28,129 @@ @bassetBlock('backpack/crud/buttons/delete-button-'.app()->getLocale().'.js') @endBassetBlock @if (!request()->ajax()) @endpush @endif diff --git a/src/resources/views/crud/chips/general.blade.php b/src/resources/views/crud/chips/general.blade.php new file mode 100644 index 0000000000..cbd2f5945c --- /dev/null +++ b/src/resources/views/crud/chips/general.blade.php @@ -0,0 +1,85 @@ +@php + $defaultHeading = [ + 'content' => null, // text to show in the heading + 'element' => 'a', + 'class' => 'mb-1 d-inline-block', + ]; + + $defaultImage = [ + 'content' => null, // image url + 'element' => 'a', + 'class' => 'avatar avatar-2 rounded', + ]; + + // merge any passed parameters with the defaults + $heading = array_merge($defaultHeading, $heading ?? []); // the main heading showing in the chip + $image = array_merge($defaultImage, $image ?? []); // the image that shows up in the chip (if any) + $details = $details ?? []; // the details that show up on the second row (if any) + + // ensure the details have the minimum info + foreach ($details as $key => $detail) { + $details[$key]['element'] = $detail['element'] ?? 'span'; + $details[$key]['class'] = $detail['class'] ?? 'text-reset'; + } + + // if the heading has a href, target and tile, and the image does not + // then use those for the image as well + if ($image['content'] !== null && $heading['content'] !== null) { + $image['href'] = $image['href'] ?? $heading['href'] ?? null; + $image['title'] = $image['title'] ?? $heading['title'] ?? null; + $image['target'] = $image['target'] ?? $heading['target'] ?? null; + } +@endphp + +
+ @if ($image['content']) +
+
+ @if ($image['content']) + <{{ $image['element'] }} + @foreach ($image as $attribute => $value) + @if ($attribute !== 'element' && $attribute !== 'content') + {{ $attribute }}="{{ $value }}" + @endif + @endforeach + > + + + @endif +
+
+ @endif +
+
+ @if ($heading['content']) + <{{ $heading['element'] }} + @foreach ($heading as $attribute => $value) + @if ($attribute !== 'element' && $attribute !== 'content') + {{ $attribute }}="{{ $value }}" + @endif + @endforeach + > + {{ $heading['content'] }} + + @endif +
+
+ @foreach ($details as $key => $detail) + + @if (isset($detail['icon'])) + + @endif + <{{ $detail['element'] }} + @foreach ($detail as $attribute => $value) + @if ($attribute !== 'element' && $attribute !== 'icon' && $attribute !== 'content') + {{ $attribute }}="{{ $value }}" + @endif + @endforeach + > + {{ $detail['content'] }} + + + @endforeach +
+
+
diff --git a/src/resources/views/crud/columns/inc/bulk_actions_checkbox.blade.php b/src/resources/views/crud/columns/inc/bulk_actions_checkbox.blade.php index 4774ada5b6..b3d0d75d4c 100644 --- a/src/resources/views/crud/columns/inc/bulk_actions_checkbox.blade.php +++ b/src/resources/views/crud/columns/inc/bulk_actions_checkbox.blade.php @@ -6,122 +6,302 @@ - +@endif +@push('after_scripts') @bassetBlock('backpack/crud/operations/list/bulk-actions-checkbox.js') - - @endBassetBlock -@endif +// Initialize all existing tables on page load +document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.bulk-button').forEach(btn => { + btn.classList.add('disabled'); + }); +}); + +// Add event listener for DataTables initialization +window.addEventListener('backpack:table:initialized', function(e) { + const tableId = e.detail.tableId; + + // Check if the table has bulk actions + const tableElement = document.getElementById(tableId); + if (!tableElement) { + console.error(`Table element #${tableId} not found`); + return; + } + + const hasBulkActions = tableElement.getAttribute('data-has-bulk-actions') === 'true' || + tableElement.getAttribute('data-has-bulk-actions') === '1'; + + if (hasBulkActions) { + // Make sure the function is called on each draw event + if (window.crud.tables[tableId]) { + window.crud.tables[tableId].on('draw.dt', function() { + window.registerBulkActionsCheckboxes(tableId); + }); + } + } +}); + +@endBassetBlock +@endpush \ No newline at end of file diff --git a/src/resources/views/crud/components/dataform/form.blade.php b/src/resources/views/crud/components/dataform/form.blade.php new file mode 100644 index 0000000000..0696a8e8fd --- /dev/null +++ b/src/resources/views/crud/components/dataform/form.blade.php @@ -0,0 +1,25 @@ +
+ @include('crud::inc.grouped_errors', ['id' => $id]) + + +
+ {!! csrf_field() !!} + + + @if($method !== 'post') + @method($method) + @endif + {{-- Include the form fields --}} + @include('crud::form_content', ['fields' => $crud->fields(), 'action' => $operation, 'id' => $id]) + + {{-- This makes sure that all field assets are loaded. --}} +
{{ json_encode(Basset::loaded()) }}
+ + @include('crud::inc.form_save_buttons') +
+
\ No newline at end of file diff --git a/src/resources/views/crud/components/datagrid.blade.php b/src/resources/views/crud/components/datagrid.blade.php new file mode 100644 index 0000000000..af8c61a2cf --- /dev/null +++ b/src/resources/views/crud/components/datagrid.blade.php @@ -0,0 +1,19 @@ +
+ @foreach($columns as $column) +
+
{!! $column['label'] !!}
+
+ @includeFirst(\Backpack\CRUD\ViewNamespaces::getViewPathsWithFallbackFor('columns', $column['type'], 'crud::columns.text')) +
+
+ @endforeach + + @if($displayButtons && $crud && $crud->buttons()->where('stack', 'line')->count()) +
+
{{ trans('backpack::crud.actions') }}
+
+ @include('crud::inc.button_stack', ['stack' => 'line']) +
+
+ @endif +
diff --git a/src/resources/views/crud/components/datalist.blade.php b/src/resources/views/crud/components/datalist.blade.php new file mode 100644 index 0000000000..e49625e2bd --- /dev/null +++ b/src/resources/views/crud/components/datalist.blade.php @@ -0,0 +1,25 @@ + + + @foreach($columns as $column) + + + + + @endforeach + + @if($displayButtons && $crud && $crud->buttons()->where('stack', 'line')->count()) + + + + + @endif + +
index === 0) class="border-top-0" @endif> + {!! $column['label'] !!}@if(!empty($column['label'])):@endif + index === 0) class="border-top-0" @endif> + @includeFirst(\Backpack\CRUD\ViewNamespaces::getViewPathsWithFallbackFor('columns', $column['type'], 'crud::columns.text')) +
+ {{ trans('backpack::crud.actions') }} + + @include('crud::inc.button_stack', ['stack' => 'line']) +
diff --git a/src/resources/views/crud/components/datatable/datatable.blade.php b/src/resources/views/crud/components/datatable/datatable.blade.php new file mode 100644 index 0000000000..1cabf4d8cb --- /dev/null +++ b/src/resources/views/crud/components/datatable/datatable.blade.php @@ -0,0 +1,151 @@ +@php + // Define the table ID - use the provided tableId or default to 'crudTable' + $tableId = $tableId ?? 'crudTable'; +@endphp +
+

{!! $crud->getHeading() ?? $crud->entity_name_plural !!}

+

{!! $crud->getSubheading() ?? '' !!}

+
+
+
+ @if ( $crud->buttons()->where('stack', 'top')->count() || $crud->exportButtons()) +
+ @include('crud::inc.button_stack', ['stack' => 'top']) +
+ @endif +
+ @if($crud->getOperationSetting('searchableTable')) +
+
+
+ + + + +
+
+
+ @endif +
+ +{{-- Backpack List Filters --}} +@if ($crud->filtersEnabled()) + @include('crud::inc.filters_navbar', ['componentId' => $tableId]) +@endif +
+ + + + {{-- Table columns --}} + @foreach ($crud->columns() as $column) + @php + $exportOnlyColumn = $column['exportOnlyColumn'] ?? false; + $visibleInTable = $column['visibleInTable'] ?? ($exportOnlyColumn ? false : true); + $visibleInModal = $column['visibleInModal'] ?? ($exportOnlyColumn ? false : true); + $visibleInExport = $column['visibleInExport'] ?? true; + $forceExport = $column['forceExport'] ?? (isset($column['exportOnlyColumn']) ? true : false); + @endphp + + @endforeach + + @if ( $crud->buttons()->where('stack', 'line')->count() ) + + @endif + + + + + + + {{-- Table columns --}} + @foreach ($crud->columns() as $column) + + @endforeach + + @if ( $crud->buttons()->where('stack', 'line')->count() ) + + @endif + + +
if developer forced column to be in the table with 'visibleInTable => true' + data-visible => regular visibility of the column + data-can-be-visible-in-table => prevents the column to be visible into the table (export-only) + data-visible-in-modal => if column appears on responsive modal + data-visible-in-export => if this column is exportable + data-force-export => force export even if columns are hidden + --}} + + data-visible="{{ $exportOnlyColumn ? 'false' : var_export($visibleInTable) }}" + data-visible-in-table="{{ var_export($visibleInTable) }}" + data-can-be-visible-in-table="{{ $exportOnlyColumn ? 'false' : 'true' }}" + data-visible-in-modal="{{ var_export($visibleInModal) }}" + data-visible-in-export="{{ $exportOnlyColumn ? 'true' : ($visibleInExport ? 'true' : 'false') }}" + data-force-export="{{ var_export($forceExport) }}" + > + {{-- Bulk checkbox --}} + @if($loop->first && $crud->getOperationSetting('bulkActions')) + {!! View::make('crud::columns.inc.bulk_actions_checkbox')->render() !!} + @endif + {!! $column['label'] !!} + {{ trans('backpack::crud.actions') }}
+ {{-- Bulk checkbox --}} + @if($loop->first && $crud->getOperationSetting('bulkActions')) + {!! View::make('crud::columns.inc.bulk_actions_checkbox')->render() !!} + @endif + {!! $column['label'] !!} + {{ trans('backpack::crud.actions') }}
+
+ +@if ( $crud->buttons()->where('stack', 'bottom')->count() ) +
+ @include('crud::inc.button_stack', ['stack' => 'bottom']) + +
+@endif + +@section('after_styles') + {{-- CRUD LIST CONTENT - crud_list_styles stack --}} + @stack('crud_list_styles') +@endsection + +@section('after_scripts') + @include('crud::components.datatable.datatable_logic', ['tableId' => $tableId]) + @include('crud::inc.export_buttons') + @include('crud::inc.details_row_logic') + + {{-- CRUD LIST CONTENT - crud_list_scripts stack --}} + @stack('crud_list_scripts') +@endsection diff --git a/src/resources/views/crud/components/datatable/datatable_logic.blade.php b/src/resources/views/crud/components/datatable/datatable_logic.blade.php new file mode 100644 index 0000000000..2393dfb1c4 --- /dev/null +++ b/src/resources/views/crud/components/datatable/datatable_logic.blade.php @@ -0,0 +1,893 @@ +@php +// as it is possible that we can be redirected with persistent table we save the alerts in a variable +// and flush them from session, so we will get them later from localStorage. +$backpack_alerts = \Alert::getMessages(); +\Alert::flush(); +@endphp + +{{-- DATA TABLES SCRIPT --}} +@basset("https://cdn.datatables.net/2.1.8/js/dataTables.min.js") +@basset("https://cdn.datatables.net/2.1.8/js/dataTables.bootstrap5.min.js") +@basset("https://cdn.datatables.net/responsive/3.0.3/js/dataTables.responsive.min.js") +@basset('https://cdn.datatables.net/fixedheader/4.0.1/js/dataTables.fixedHeader.min.js') +@basset(base_path('vendor/backpack/crud/src/resources/assets/img/spinner.svg'), false) + +@push('before_styles') + @basset('https://cdn.datatables.net/2.1.8/css/dataTables.bootstrap5.min.css') + @basset("https://cdn.datatables.net/responsive/3.0.3/css/responsive.dataTables.min.css") + @basset('https://cdn.datatables.net/fixedheader/4.0.1/css/fixedHeader.dataTables.min.css') +@endpush + + \ No newline at end of file diff --git a/src/resources/views/crud/create.blade.php b/src/resources/views/crud/create.blade.php index f6dd72ad85..af3abc33f5 100644 --- a/src/resources/views/crud/create.blade.php +++ b/src/resources/views/crud/create.blade.php @@ -33,7 +33,7 @@
{{-- Default box --}} - @include('crud::inc.grouped_errors') + @include('crud::inc.grouped_errors', ['id' => $id ?? null])
- {!! csrf_field() !!} - {{-- load the view from the application if it exists, otherwise load the one in the package --}} - @if(view()->exists('vendor.backpack.crud.form_content')) - @include('vendor.backpack.crud.form_content', [ 'fields' => $crud->fields(), 'action' => 'create' ]) - @else - @include('crud::form_content', [ 'fields' => $crud->fields(), 'action' => 'create' ]) - @endif - {{-- This makes sure that all field assets are loaded. --}} -
{{ json_encode(Basset::loaded()) }}
- @include('crud::inc.form_save_buttons') + {!! csrf_field() !!} + {{-- load the view from the application if it exists, otherwise load the one in the package --}} + @if(view()->exists('vendor.backpack.crud.form_content')) + @include('vendor.backpack.crud.form_content', [ 'fields' => $crud->fields(), 'action' => 'create', 'id' => $id ?? null]) + @else + @include('crud::form_content', [ 'fields' => $crud->fields(), 'action' => 'create', 'id' => $id ?? null]) + @endif + {{-- This makes sure that all field assets are loaded. --}} +
{{ json_encode(Basset::loaded()) }}
+ @include('crud::inc.form_save_buttons')
diff --git a/src/resources/views/crud/edit.blade.php b/src/resources/views/crud/edit.blade.php index fcb3959077..f6d81f9122 100644 --- a/src/resources/views/crud/edit.blade.php +++ b/src/resources/views/crud/edit.blade.php @@ -28,7 +28,7 @@
{{-- Default box --}} - @include('crud::inc.grouped_errors') + @include('crud::inc.grouped_errors', ['id' => $id])
exists('vendor.backpack.crud.form_content')) - @include('vendor.backpack.crud.form_content', ['fields' => $crud->fields(), 'action' => 'edit']) + @include('vendor.backpack.crud.form_content', ['fields' => $crud->fields(), 'action' => 'edit', 'id' => $id ?? null]) @else - @include('crud::form_content', ['fields' => $crud->fields(), 'action' => 'edit']) + @include('crud::form_content', ['fields' => $crud->fields(), 'action' => 'edit', 'id' => $id ?? null]) @endif {{-- This makes sure that all field assets are loaded. --}}
{{ json_encode(Basset::loaded()) }}
diff --git a/src/resources/views/crud/fields/summernote.blade.php b/src/resources/views/crud/fields/summernote.blade.php index ad04ba173a..4a8e08d4b8 100644 --- a/src/resources/views/crud/fields/summernote.blade.php +++ b/src/resources/views/crud/fields/summernote.blade.php @@ -13,6 +13,9 @@ name="{{ $field['name'] }}" data-init-function="bpFieldInitSummernoteElement" data-options="{{ json_encode($field['options']) }}" + data-upload-enabled="{{ isset($field['withFiles']) || isset($field['withMedia']) || isset($field['imageUploadEndpoint']) ? 'true' : 'false'}}" + data-upload-endpoint="{{ isset($field['imageUploadEndpoint']) ? $field['imageUploadEndpoint'] : 'false'}}" + data-upload-operation="{{ $crud->get('ajax-upload.formOperation') }}" bp-field-main-input @include('crud::fields.inc.attributes', ['default_class' => 'form-control summernote']) >{{ old_empty_or_null($field['name'], '') ?? $field['value'] ?? $field['default'] ?? '' }} @@ -31,8 +34,8 @@ {{-- FIELD CSS - will be loaded in the after_styles section --}} @push('crud_fields_styles') {{-- include summernote css --}} - @basset('https://unpkg.com/summernote@0.8.20/dist/summernote-lite.min.css') - @basset('https://unpkg.com/summernote@0.8.20/dist/font/summernote.woff2', false) + @basset('https://unpkg.com/summernote@0.9.1/dist/summernote-lite.min.css') + @basset('https://unpkg.com/summernote@0.9.1/dist/font/summernote.woff2', false) @bassetBlock('backpack/crud/fields/summernote-field.css')