voyager-admin/voyager

View on GitHub
src/Manager/Breads.php

Summary

Maintainability
C
1 day
Test Coverage
C
71%
<?php

namespace Voyager\Admin\Manager;

use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Voyager\Admin\Classes\Bread as BreadClass;
use Voyager\Admin\Classes\Formfield;
use Voyager\Admin\Classes\Layout;
use Voyager\Admin\Contracts\Plugins\Features\Filter\Layouts as LayoutFilter;
use Voyager\Admin\Exceptions\NoLayoutFoundException;
use Voyager\Admin\Facades\Voyager as VoyagerFacade;
use Voyager\Admin\Manager\Plugins as PluginManager;

class Breads
{
    protected Collection $formfields;
    protected string $path;
    protected ?Collection $breads = null;
    protected array $backups = [];
    protected Collection $actions;

    public function __construct(protected PluginManager $pluginmanager)
    {
        $this->path = Str::finish(storage_path('voyager/breads'), '/');
        $this->formfields = collect();
        $this->actions = collect();
    }

    /**
     * Sets the path where the BREAD-files are stored.
     *
     * @param string $path
     * @return string the current path.
     */
    public function setPath($path = null)
    {
        if ($path) {
            $old_path = $this->path;
            $this->path = Str::finish($path, '/');
            if ($old_path !== $path) {
                $this->breads = null;
            }
        }

        return $this->path;
    }

    /**
     * Get all BREADs from storage and validate.
     *
     * @return \Illuminate\Support\Collection<string, BreadClass>
     */
    public function getBreads()
    {
        if (!$this->breads) {
            VoyagerFacade::ensureDirectoryExists($this->path);
            $this->breads = collect(File::files($this->path))->transform(function ($bread) {
                $content = File::get($bread->getPathName());
                $json = VoyagerFacade::getJson($content);
                if ($json === false) {
                    VoyagerFacade::flashMessage('BREAD-file "'.basename($bread->getPathName()).'" does contain invalid JSON: '.json_last_error_msg(), 'yellow');

                    return;
                }

                $b = new BreadClass($json);

                // Push Exclude backups
                if (Str::contains($bread->getPathName(), '.backup.')) {
                    $date = Str::before(Str::after($bread->getFilename(), '.backup.'), '.json');
                    $this->backups[] = [
                        'table' => $b->table,
                        'path'  => $bread->getFilename(),
                        'date'  => $date,
                    ];

                    return null;
                }

                return $b;
            })->filter(function ($bread) {
                return $bread !== null && $bread instanceof BreadClass;
            })->values()->mapWithKeys(static function ($value) {
                return [$value->name_singular => $value];
            });
        }

        return $this->breads;
    }

    /**
     * Get backed-up BREADs.
     *
     * @return array
     */
    public function getBackups()
    {
        $this->breads = null;
        $this->backups = [];
        $this->getBreads();

        return $this->backups;
    }

    /**
     * Rollback BREAD to a given file.
     *
     * @param string $path
     *
     * @return bool
     */
    public function rollbackBread(string $table, string $path): string|bool
    {
        $path = Str::finish($this->path, '/');
        if ($this->backupBread($table) !== false) {
            return File::delete($path.$table.'.json') && File::copy($path.$path, $path.$table.'.json');
        }

        return false;
    }

    /**
     * Determine if a BREAD exists by the table name.
     *
     * @param string $table
     *
     * @return bool
     */
    public function hasBread($table)
    {
        return $this->getBread($table) !== null;
    }

    /**
     * Get a BREAD by the table name.
     *
     * @param string $table
     *
     * @return \Voyager\Admin\Classes\Bread
     */
    public function getBread($table): ?BreadClass
    {
        return $this->getBreads()->where('table', $table)->first();
    }

    /**
     * Get a BREAD by the table name.
     */
    public function getBreadByName(string $breadName): ?BreadClass
    {
        return $this->getBreads()->get($breadName);
    }

    /**
     * Store a BREAD-file.
     *
     * @param \Voyager\Admin\Classes\Bread|\stdClass $bread
     *
     * @return bool success
     */
    public function storeBread(BreadClass|\stdClass $bread): bool
    {
        $this->clearBreads();

        if (!$bread instanceof BreadClass) {
            $bread = new BreadClass($bread);
        }

        return VoyagerFacade::writeToFile(Str::finish($this->path, '/').$bread->table.'.json', json_encode($bread, JSON_PRETTY_PRINT));
    }

    /**
     * Create a BREAD-object.
     *
     * @param string $table
     *
     * @return \Voyager\Admin\Classes\Bread
     */
    public function createBread($table)
    {
        // Guess the model name
        $name = Str::singular(Str::studly($table));

        $namespace = Str::start(Str::finish(Container::getInstance()->getNamespace() ?? 'App\\', '\\'), '\\'); // @phpstan-ignore-line
        $model = (is_dir(app_path('Models')) ? $namespace.'Models\\' : $namespace).$name;

        if (!class_exists($model)) {
            $model = null;
        }

        $bread = [
            'table'         => $table,
            'slug'          => Str::slug($table),
            'name_singular' => str_replace('_', ' ', Str::singular(Str::title($table))),
            'name_plural'   => str_replace('_', ' ', Str::plural(Str::title($table))),
            'model'         => $model,
            'layouts'       => [],
        ];

        return new BreadClass($bread);
    }

    /**
     * Clears all BREAD-objects.
     */
    public function clearBreads(): void
    {
        $this->breads = null;
    }

    /**
     * Delete a BREAD from the filesystem.
     *
     * @param string $table The table of the BREAD
     */
    public function deleteBread(string $table): bool
    {
        $ret = File::delete(Str::finish($this->path, '/').$table.'.json');
        $this->clearBreads();

        return $ret;
    }

    /**
     * Backup a BREAD (copy table.json to table.backup.json).
     *
     * @param  string $table The table of the BREAD
     * @return string|bool The name of the backup file or false when failed
     */
    public function backupBread(string $table): string|bool
    {
        $old = $this->path.$table.'.json';
        $name = $table.'.backup.'.Carbon::now()->isoFormat('Y-MM-DD@HH-mm-ss').'.json';
        $new = $this->path.$name;

        if (File::exists($old)) {
            if (!File::copy($old, $new)) {
                return false;
            }
        }

        return $name;
    }

    /**
     * Get the search placeholder (Search for Users, Posts, etc...).
     *
     * @return array|string|null $placeholder The placeholder
     */
    public function getBreadSearchPlaceholder()
    {
        $breads = $this->getBreads()
        ->reject(function ($bread) {
            // TODO: Authorize if user is able to browse this bread
            return empty($bread->global_search_field);
        })->shuffle();

        if ($breads->count() > 1) {
            return __('voyager::generic.search_for_breads', [
                'bread'  => $breads[0]?->name_plural,
                'bread2' => $breads[1]?->name_plural,
            ]);
        } elseif ($breads->count() == 1) {
            return __('voyager::generic.search_for_bread', [
                'bread' => $breads[0]?->name_plural,
            ]);
        }

        return __('voyager::generic.search');
    }

    /**
     * Add a formfield.
     *
     * @param mixed $class The class of the formfield
     */
    public function addFormfield(mixed $class): void
    {
        if (!$class instanceof Formfield) {
            $class = new $class();
        }

        if (!method_exists($class, 'name')) {
            throw new \Exception('Formfields need to implement the "name" method.');
        } elseif (!method_exists($class, 'type')) {
            throw new \Exception('Formfields need to implement the "type" method.');
        }

        $this->formfields->push($class);
    }

    /**
     * Get formfields.
     *
     * @return \Illuminate\Support\Collection The formfields
     */
    public function getFormfields()
    {
        return $this->formfields->map(function ($formfield) {
            $component = 'Formfield'.Str::studly($formfield->type());
            $builder_component = 'Formfield'.Str::studly($formfield->type()).'Builder';

            if ($formfield instanceof Formfield) {
                if (method_exists($formfield, 'getComponentName')) {
                    $component = $formfield->getComponentName();
                }
                if (method_exists($formfield, 'getBuilderComponentName')) {
                    $builder_component = $formfield->getBuilderComponentName();
                }
            }
            return [
                'name'                      => $formfield->name(),
                'type'                      => $formfield->type(),
                'can_be_translated'         => !property_exists($formfield, 'notTranslatable'),
                'in_settings'               => !property_exists($formfield, 'notAsSetting'),
                'in_lists'                  => !property_exists($formfield, 'notInLists'),
                'in_views'                  => !property_exists($formfield, 'notInViews'),
                'browse_array'              => property_exists($formfield, 'browseArray'),
                'allow_columns'             => !property_exists($formfield, 'noColumns'),
                'allow_computed_props'      => !property_exists($formfield, 'noComputedProps'),
                'allow_relationships'       => !property_exists($formfield, 'noRelationships'),
                'allow_relationship_props'  => !property_exists($formfield, 'noRelationshipProps'),
                'allow_relationship_pivots' => !property_exists($formfield, 'noRelationshipPivots'),
                'component'                 => $component,
                'builder_component'         => $builder_component,
            ];
        });
    }

    /**
     * Get a formfield by type.
     */
    public function getFormfield(string $type): ?Formfield
    {
        return $this->formfields->filter(function ($formfield) use ($type) {
            return $formfield->type() == $type;
        })->first();
    }

    public function getFormfieldClass(string $type): ?string
    {
        return $this->formfields->filter(function ($formfield) use ($type) {
            return $formfield->type() == $type;
        })->transform(function ($formfield) {
            return get_class($formfield);
        })->first();
    }

    /**
     * Get the reflection class for a model.
     *
     * @param class-string $model The fully qualified model name
     *
     * @return \ReflectionClass The reflection object
     */
    public function getModelReflectionClass(string $model): \ReflectionClass
    {
        return new \ReflectionClass($model);
    }

    public function getModelScopes(\ReflectionClass $reflection): Collection
    {
        return collect($reflection->getMethods())->filter(function ($method) {
            return Str::startsWith($method->name, 'scope');
        })->whereNotIn('name', ['scopeWithTranslations', 'scopeWithTranslation', 'scopeWhereTranslation'])->transform(function ($method) {
            return lcfirst(Str::replaceFirst('scope', '', $method->name));
        });
    }

    public function getModelComputedProperties(\ReflectionClass $reflection): Collection
    {
        return collect($reflection->getMethods())->filter(function ($method) {
            return Str::startsWith($method->name, 'get') && Str::endsWith($method->name, 'Attribute');
        })->transform(function ($method) {
            $name = Str::replaceFirst('get', '', $method->name);
            $name = Str::replaceLast('Attribute', '', $name);

            return lcfirst($name);
        })->filter();
    }

    public function getModelRelationships(\ReflectionClass $reflection, Model $model, bool $resolve = false): Collection
    {
        $single = [
            BelongsTo::class,
            HasOne::class,
            HasOneThrough::class,
        ];

        $multi = [
            BelongsToMany::class,
            HasMany::class,
            HasManyThrough::class,
        ];

        return collect($reflection->getMethods())->transform(function ($method) use ($single, $multi, $model, $resolve) {
            $type = $method->getReturnType();
            if ($type && in_array(strval($type->getName()), array_merge($single, $multi))) {
                $columns = [];
                $scopes = [];
                $pivot = [];
                $table = '';
                $relationship = null;
                $bread = null;
                if ($resolve) {
                    $relationship = $model->{$method->getName()}();
                    $related = $relationship->getRelated();
                    $table = $related->getTable();
                    $bread = $this->getBread($table);
                    if ($type->getName() == BelongsToMany::class) {
                        $pivot = array_values(array_diff(VoyagerFacade::getColumns($relationship->getTable()), [
                            $relationship->getForeignPivotKeyName(),
                            $relationship->getRelatedPivotKeyName(),
                        ]));
                    }
                    if (get_class($related) !== false) {
                        $relationship_reflection = $this->getModelReflectionClass(get_class($related));
                        $columns = array_merge(VoyagerFacade::getColumns($table), $this->getModelComputedProperties($relationship_reflection)->values()->transform(function ($name) {
                            return 'computed.'.$name;
                        })->toArray());

                        $scopes = $this->getModelScopes($relationship_reflection)->values();
                    }
                }

                return [
                    'method'    => $method->getName(),
                    'type'      => class_basename($type->getName()),
                    'fqcn'      => $type->getName(),
                    'table'     => $table,
                    'columns'   => $columns,
                    'scopes'    => $scopes,
                    'pivot'     => $pivot,
                    'key_name'  => $relationship ? $relationship->getRelated()->getKeyName() : '',
                    'multiple'  => in_array(strval($type->getName()), $multi),
                    'bread'     => $bread,
                ];
            }

            return null;
        })->filter();
    }

    /**
     * Add an action to BREADs.
     *
     * @param \Voyager\Admin\Classes\Action $action The action instance.
     */
    public function addAction(\Voyager\Admin\Classes\Action $action): void
    {
        $this->actions->push($action);
    }

    /**
     * Manipulate all actions.
     *
     * @param callable $callback A callback which gets all actions and returns a manipulated version of them.
     */
    public function manipulateActions(callable $callback): void
    {
        $this->actions = $callback($this->actions);
    }

    /**
     * Gets all actions for a BREAD.
     *
     * @param BreadClass $bread The BREAD.
     * @return Collection The collection of Actions
     */
    public function getActionsForBread(BreadClass $bread): Collection
    {
        return $this->actions->filter(function ($action) use ($bread) {
            $display = true;
            if (is_callable($action->callback)) {
                $display = $action->callback->call($this, $bread) ?? true; // @phpstan-ignore-line
            }

            if (!is_null($action->permission)) {
                if (!VoyagerFacade::authorize(VoyagerFacade::auth()->user(), $action->permission, [$bread])) {
                    $display = false;
                }
            }

            return $display;
        })->transform(function ($action) use ($bread) {
            // Resolve route
            if (is_callable($action->route_callback)) {
                $action->route_name = $action->route_callback->call($this, $bread) ?? '#'; // @phpstan-ignore-line
            } else {
                $action->route_name = $action->route_callback;
            }

            return $action;
        });
    }

    public function getLayoutForAction(BreadClass $bread, string $action): Layout
    {
        $layouts = $bread->layouts->where('type', $action == 'browse' ? 'list' : 'view');

        $this->pluginmanager->getAllPlugins()->each(function ($plugin) use ($bread, $action, &$layouts) {
            if ($plugin instanceof LayoutFilter) {
                $layouts = $plugin->filterLayouts($bread, $action, $layouts);
            }
        });

        if ($layouts->first() === null) {
            throw new NoLayoutFoundException(__('voyager::bread.no_layout_assigned', ['action' => ucfirst($action)])); // @phpstan-ignore-line
        }

        return $layouts->first();
    }

    public function getLayoutsForAction(BreadClass $bread, string $action): Collection
    {
        $layouts = $bread->layouts->where('type', $action == 'browse' ? 'list' : 'view');

        $this->pluginmanager->getAllPlugins()->each(function ($plugin) use ($bread, $action, &$layouts) {
            if ($plugin instanceof LayoutFilter) {
                $layouts = $plugin->filterLayouts($bread, $action, $layouts);
            }
        });

        return $layouts;
    }
}