├── .nvmrc ├── README.md ├── composer.json ├── config └── nova-order-nestedset-field.php ├── dist ├── js │ └── field.js └── mix-manifest.json ├── lang ├── en │ ├── errors.php │ └── messages.php ├── fa │ ├── errors.php │ └── messages.php └── fr │ ├── errors.php │ └── messages.php ├── nova.mix.js ├── package.json ├── phpstan.dist.neon ├── resources └── js │ ├── components │ └── IndexField.vue │ └── field.js ├── routes └── api.php ├── src ├── Http │ └── Controllers │ │ └── PositionController.php ├── OrderNestedsetField.php ├── OrderNestedsetFieldServiceProvider.php └── Traits │ └── Orderable.php └── webpack.mix.js /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/gallium 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nova Order Field nestedset 2 | [![Travis](https://img.shields.io/travis/novius/laravel-nova-order-nestedset-field.svg?maxAge=1800&style=flat-square)](https://travis-ci.org/novius/laravel-nova-order-nestedset-field) 3 | [![Packagist Release](https://img.shields.io/packagist/v/novius/laravel-nova-order-nestedset-field.svg?maxAge=1800&style=flat-square)](https://packagist.org/packages/novius/laravel-nova-order-nestedset-field) 4 | [![Licence](https://img.shields.io/packagist/l/novius/laravel-nova-order-nestedset-field.svg?maxAge=1800&style=flat-square)](https://github.com/novius/laravel-nova-order-nestedset-field#licence) 5 | 6 | A field that make your resources orderable using [the laravel nestedset package](https://github.com/lazychaser/laravel-nestedset). 7 | 8 | ## Requirements 9 | 10 | * PHP >= 8.1 11 | * Laravel Nova >= 4.0 12 | 13 | > **NOTE**: These instructions are for Laravel Nova 4.0. If you are using prior version, please 14 | > see the [previous version's docs](https://github.com/novius/laravel-nova-order-nestedset-field/tree/3.x). 15 | 16 | ## Installation 17 | 18 | ```sh 19 | composer require novius/laravel-nova-order-nestedset-field 20 | ``` 21 | 22 | ### Configuration 23 | 24 | Some options that you can override are available. 25 | 26 | ```sh 27 | php artisan vendor:publish --provider="Novius\LaravelNovaOrderNestedsetField\OrderNestedsetFieldServiceProvider" --tag="config" 28 | ``` 29 | 30 | ## Usage 31 | 32 | **Step 1** 33 | 34 | Use Kalnoy\Nestedset `NodeTrait` and Novius\LaravelNovaOrderNestedsetField `Orderable` trait on your model. 35 | 36 | Example : 37 | 38 | ```php 39 | use Kalnoy\Nestedset\NodeTrait; 40 | use Novius\LaravelNovaOrderNestedsetField\Traits\Orderable; 41 | 42 | class Foo extends Model { 43 | use NodeTrait; 44 | use Orderable; 45 | 46 | public function getLftName() 47 | { 48 | return 'left'; 49 | } 50 | 51 | public function getRgtName() 52 | { 53 | return 'right'; 54 | } 55 | 56 | public function getParentIdName() 57 | { 58 | return 'parent'; 59 | } 60 | } 61 | 62 | ``` 63 | 64 | **Step 2** 65 | 66 | Add the field to your resource and specify order for your resources. 67 | 68 | 69 | ```php 70 | use Novius\LaravelNovaOrderNestedsetField\OrderNestedsetField; 71 | 72 | class FooResource extends Resource 73 | { 74 | public function fields(Request $request) 75 | { 76 | return [ 77 | OrderNestedsetField::make('Order'), 78 | ]; 79 | } 80 | 81 | /** 82 | * @param \Illuminate\Database\Eloquent\Builder $query 83 | * @param array $orderings 84 | * @return \Illuminate\Database\Eloquent\Builder 85 | */ 86 | protected static function applyOrderings($query, array $orderings) 87 | { 88 | return $query->orderBy('left', 'asc'); 89 | } 90 | } 91 | 92 | ``` 93 | 94 | **Scoping** 95 | 96 | Imagine you have `Menu` model and `MenuItems`. There is a one-to-many relationship 97 | set up between these models. `MenuItem` has `menu_id` attribute for joining models 98 | together. `MenuItem` incorporates nested sets. It is obvious that you would want to 99 | process each tree separately based on `menu_id` attribute. In order to do so, you 100 | need to specify this attribute as scope attribute: 101 | 102 | ```php 103 | protected function getScopeAttributes() 104 | { 105 | return ['menu_id']; 106 | } 107 | ``` 108 | 109 | [Retrieve more information about usage on official doc](https://github.com/lazychaser/laravel-nestedset#scoping). 110 | 111 | ## Performances 112 | 113 | You can enable cache to avoid performance issues in case of large tree. 114 | 115 | **By default cache is disabled.** 116 | 117 | To use cache you have to enabled it in config file with : 118 | 119 | ```php 120 | return [ 121 | ... 122 | 123 | 'cache_enabled' => true, 124 | ]; 125 | ``` 126 | 127 | **You have to clear cache on every tree updates with an observer on your Model (or directly in boot method).** 128 | 129 | ```php 130 | namespace App\Models; 131 | 132 | use Illuminate\Database\Eloquent\Model; 133 | use Kalnoy\Nestedset\NodeTrait; 134 | use Novius\LaravelNovaOrderNestedsetField\Traits\Orderable; 135 | 136 | class Foo extends Model 137 | { 138 | use NodeTrait; 139 | use Orderable; 140 | 141 | public static function boot() 142 | { 143 | parent::boot(); 144 | 145 | if (config('nova-order-nestedset-field.cache_enabled', false)) { 146 | static::created(function (Theme $model) { 147 | $model->clearOrderableCache(); 148 | }); 149 | 150 | static::updated(function (Theme $model) { 151 | $model->clearOrderableCache(); 152 | }); 153 | 154 | static::deleted(function (Theme $model) { 155 | $model->clearOrderableCache(); 156 | }); 157 | } 158 | } 159 | } 160 | ``` 161 | 162 | ## Override default languages files 163 | 164 | Run: 165 | 166 | ```sh 167 | php artisan vendor:publish --provider="Novius\LaravelNovaOrderNestedsetField\OrderNestedsetFieldServiceProvider" --tag="lang" 168 | ``` 169 | 170 | ## Lint 171 | 172 | Run php-cs with: 173 | 174 | ```sh 175 | composer run-script lint 176 | ``` 177 | 178 | ## Contributing 179 | 180 | Contributions are welcome! 181 | Leave an issue on Github, or create a Pull Request. 182 | 183 | 184 | ## Licence 185 | 186 | This package is under [GNU Affero General Public License v3](http://www.gnu.org/licenses/agpl-3.0.html) or (at your option) any later version. 187 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "novius/laravel-nova-order-nestedset-field", 3 | "description": "A Laravel Nova field that make your resources orderable", 4 | "keywords": [ 5 | "laravel", 6 | "nova", 7 | "nestedset", 8 | "trees", 9 | "hierarchies" 10 | ], 11 | "license": "AGPL-3.0-or-later", 12 | "authors": [ 13 | { 14 | "name": "Novius Agency", 15 | "email": "team-developpeurs@novius.com", 16 | "homepage": "https://www.novius.com" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.2", 21 | "laravel/nova": "^5.0", 22 | "laravel/framework": "^10.0 | ^11.0 | ^12.0", 23 | "kalnoy/nestedset": "^6.0.0" 24 | }, 25 | "require-dev": { 26 | "larastan/larastan": "^2.0 | ^3.0", 27 | "laravel/pint": "^1.7" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Novius\\LaravelNovaOrderNestedsetField\\": "src/" 32 | } 33 | }, 34 | "scripts": { 35 | "cs-fix": [ 36 | "./vendor/bin/pint -v" 37 | ], 38 | "lint": [ 39 | "@composer cs-fix -- --test" 40 | ], 41 | "phpstan": [ 42 | "vendor/bin/phpstan analyse --memory-limit 1G" 43 | ] 44 | }, 45 | "extra": { 46 | "laravel": { 47 | "providers": [ 48 | "Novius\\LaravelNovaOrderNestedsetField\\OrderNestedsetFieldServiceProvider" 49 | ] 50 | } 51 | }, 52 | "repositories": [ 53 | { 54 | "type": "composer", 55 | "url": "https://nova.laravel.com" 56 | } 57 | ], 58 | "config": { 59 | "sort-packages": true 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /config/nova-order-nestedset-field.php: -------------------------------------------------------------------------------- 1 | false, 5 | ]; 6 | -------------------------------------------------------------------------------- /dist/js/field.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e={744:(e,t)=>{t.Z=(e,t)=>{const o=e.__vccOpts||e;for(const[e,r]of t)o[e]=r;return o}}},t={};function o(r){var n=t[r];if(void 0!==n)return n.exports;var s=t[r]={exports:{}};return e[r](s,s.exports,o),s.exports}(()=>{const e=Vue;var t={class:"flex items-center"},r=[(0,e.createElementVNode)("svg",{xmlns:"http://www.w3.org/2000/svg",height:"22",width:"22",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",class:"fill-white"},[(0,e.createElementVNode)("circle",{cx:"12",cy:"12",r:"10"}),(0,e.createElementVNode)("polyline",{points:"8 12 12 16 16 12"}),(0,e.createElementVNode)("line",{x1:"12",x2:"12",y1:"8",y2:"16"})],-1)],n=[(0,e.createElementVNode)("svg",{xmlns:"http://www.w3.org/2000/svg",height:"22",width:"22",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",class:"fill-white"},[(0,e.createElementVNode)("circle",{cx:"12",cy:"12",r:"10"}),(0,e.createElementVNode)("polyline",{points:"16 12 12 8 8 12"}),(0,e.createElementVNode)("line",{x1:"12",x2:"12",y1:"16",y2:"8"})],-1)];const s={props:["resourceName","field"],computed:{resourceId:function(){return this.$parent.resource.id.value}},methods:{reorderResource:function(e){var t=this;Nova.request().post("/nova-vendor/nova-order-nestedset-field/".concat(this.resourceName),{direction:e,resource:this.resourceName,resourceId:this.resourceId}).then((function(){Nova.$toasted.show(t.__("nova-order-nestedset-field::messages.order_updated"),{type:"success"}),setTimeout((function(){window.location.reload()}),500)}))}}};const c=(0,o(744).Z)(s,[["render",function(o,s,c,i,d,l){return(0,e.openBlock)(),(0,e.createElementBlock)("div",t,[c.field.last!=l.resourceId?((0,e.openBlock)(),(0,e.createElementBlock)("button",{key:0,onClick:s[0]||(s[0]=(0,e.withModifiers)((function(e){return l.reorderResource("down")}),["stop"])),class:"cursor-pointer text-70 hover:text-primary mr-3"},r)):(0,e.createCommentVNode)("",!0),c.field.first!=l.resourceId?((0,e.openBlock)(),(0,e.createElementBlock)("button",{key:1,onClick:s[1]||(s[1]=(0,e.withModifiers)((function(e){return l.reorderResource("up")}),["stop"])),class:"cursor-pointer text-70 hover:text-primary"},n)):(0,e.createCommentVNode)("",!0)])}]]);Nova.booting((function(e,t){e.component("index-order-nestedset-field",c)}))})()})(); -------------------------------------------------------------------------------- /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/field.js": "/js/field.js" 3 | } 4 | -------------------------------------------------------------------------------- /lang/en/errors.php: -------------------------------------------------------------------------------- 1 | 'Error : model :model should implements following trait :class', 5 | 'bad_direction' => 'Error : bad direction', 6 | ]; 7 | -------------------------------------------------------------------------------- /lang/en/messages.php: -------------------------------------------------------------------------------- 1 | 'Position successfully updated.', 5 | ]; 6 | -------------------------------------------------------------------------------- /lang/fa/errors.php: -------------------------------------------------------------------------------- 1 | 'خطا: مدل :model باید این trait ها را پیاده‌سازی کند: :class', 5 | 'bad_direction' => 'خطا: جهت بد', 6 | ]; 7 | -------------------------------------------------------------------------------- /lang/fa/messages.php: -------------------------------------------------------------------------------- 1 | 'جایگاه با موفقیت به روز شد.', 5 | ]; 6 | -------------------------------------------------------------------------------- /lang/fr/errors.php: -------------------------------------------------------------------------------- 1 | 'Erreur : le modèle :model doit implémenter le trait :class', 5 | 'bad_direction' => 'Erreur : mauvaise direction spécifiée', 6 | ]; 7 | -------------------------------------------------------------------------------- /lang/fr/messages.php: -------------------------------------------------------------------------------- 1 | 'L\'ordre a bien été modifié.', 5 | ]; 6 | -------------------------------------------------------------------------------- /nova.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix') 2 | const webpack = require('webpack') 3 | const path = require('path') 4 | 5 | class NovaExtension { 6 | name() { 7 | return 'nova-extension' 8 | } 9 | 10 | register(name) { 11 | this.name = name 12 | } 13 | 14 | webpackPlugins() { 15 | return new webpack.ProvidePlugin({ 16 | _: 'lodash', 17 | Errors: 'form-backend-validation', 18 | }) 19 | } 20 | 21 | webpackConfig(webpackConfig) { 22 | webpackConfig.externals = { 23 | vue: 'Vue', 24 | } 25 | 26 | webpackConfig.resolve.alias = { 27 | ...(webpackConfig.resolve.alias || {}), 28 | 'laravel-nova': path.join( 29 | __dirname, 30 | '../../vendor/laravel/nova/resources/js/mixins/packages.js' 31 | ), 32 | } 33 | 34 | webpackConfig.output = { 35 | uniqueName: this.name, 36 | } 37 | } 38 | } 39 | 40 | mix.extend('nova', new NovaExtension()) 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "mix", 6 | "watch": "mix watch", 7 | "watch-poll": "mix watch -- --watch-options-poll=1000", 8 | "hot": "mix watch --hot", 9 | "prod": "npm run production", 10 | "production": "mix --production", 11 | "nova:install": "npm --prefix='../../vendor/laravel/nova' ci" 12 | }, 13 | "devDependencies": { 14 | "@vue/compiler-sfc": "^3.2.22", 15 | "form-backend-validation": "^2.3.3", 16 | "laravel-mix": "^6.0.41", 17 | "lodash": "^4.17.21", 18 | "vue-loader": "^16.8.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | - vendor/nesbot/carbon/extension.neon 4 | 5 | parameters: 6 | paths: 7 | - src 8 | - config 9 | 10 | # Level 10 is the highest level 11 | level: 5 12 | 13 | editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%' 14 | editorUrlTitle: '%%file%%:%%line%%' 15 | 16 | ignoreErrors: 17 | - identifier: varTag.unresolvableType 18 | # - '#PHPDoc tag @var#' 19 | # 20 | # excludePaths: 21 | # - ./*/*/FileToBeExcluded.php 22 | -------------------------------------------------------------------------------- /resources/js/components/IndexField.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 66 | -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | import IndexField from './components/IndexField' 2 | 3 | Nova.booting((app, store) => { 4 | app.component('index-order-nestedset-field', IndexField) 5 | }) 6 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | get('resourceId'); 16 | /** @var Model&Orderable $model */ 17 | $model = $request->findModelOrFail($resourceId); 18 | 19 | if (! in_array(Orderable::class, class_uses_recursive($model), true)) { 20 | abort(500, trans('nova-order-nestedset-field::errors.model_should_use_trait', [ 21 | 'class' => Orderable::class, 22 | 'model' => get_class($model), 23 | ])); 24 | } 25 | 26 | if (! in_array(NodeTrait::class, class_uses_recursive($model), true)) { 27 | abort(500, trans('nova-order-nestedset-field::errors.model_should_use_trait', [ 28 | 'class' => NodeTrait::class, 29 | 'model' => get_class($model), 30 | ])); 31 | } 32 | 33 | $direction = (string) $request->get('direction', ''); 34 | if (! in_array($direction, ['up', 'down'])) { 35 | abort(500, trans('nova-order-nestedset-field::errors.bad_direction')); 36 | } 37 | 38 | if ($direction === 'up') { 39 | $model->moveOrderUp(); 40 | } else { 41 | $model->moveOrderDown(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/OrderNestedsetField.php: -------------------------------------------------------------------------------- 1 | Orderable::class, 41 | 'model' => get_class($resource), 42 | ])); 43 | } 44 | 45 | if (! in_array(NodeTrait::class, class_uses_recursive($resource), true)) { 46 | abort(500, trans('nova-order-nestedset-field::errors.model_should_use_trait', [ 47 | 'class' => NodeTrait::class, 48 | 'model' => get_class($resource), 49 | ])); 50 | } 51 | 52 | if (config('nova-order-nestedset-field.cache_enabled', false)) { 53 | $cachePrefix = $resource->getOrderableCachePrefix(); 54 | $first = Cache::rememberForever($cachePrefix.'.first', static function () use ($resource) { 55 | return $resource->buildSortQuery()->ordered()->first(); 56 | }); 57 | $last = Cache::rememberForever($cachePrefix.'.last', static function () use ($resource) { 58 | return $resource->buildSortQuery()->ordered('desc')->first(); 59 | }); 60 | } else { 61 | $first = $resource->buildSortQuery()->ordered()->first(); 62 | $last = $resource->buildSortQuery()->ordered('desc')->first(); 63 | } 64 | 65 | $this->withMeta([ 66 | 'first' => is_null($first) ? null : $first->id, 67 | 'last' => is_null($last) ? null : $last->id, 68 | ]); 69 | 70 | return data_get($resource, $attribute); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/OrderNestedsetFieldServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->booted(function () { 18 | $this->routes(); 19 | }); 20 | 21 | Nova::serving(static function (ServingNova $event) { 22 | Nova::script('laravel-nova-order-nestedset-field', __DIR__.'/../dist/js/field.js'); 23 | }); 24 | 25 | $this->publishes([__DIR__.'/../config' => config_path()], 'config'); 26 | 27 | $this->loadTranslationsFrom(__DIR__.'/../lang', 'nova-order-nestedset-field'); 28 | $this->publishes([__DIR__.'/../lang' => lang_path('vendor/nova-order-nestedset-field')], 'lang'); 29 | } 30 | 31 | /** 32 | * Register any application services. 33 | */ 34 | public function register(): void 35 | { 36 | $this->mergeConfigFrom( 37 | __DIR__.'/../config/nova-order-nestedset-field.php', 38 | 'nova-order-nestedset-field' 39 | ); 40 | } 41 | 42 | /** 43 | * Register the tool's routes. 44 | */ 45 | protected function routes(): void 46 | { 47 | /** @phpstan-ignore method.notFound */ 48 | if ($this->app->routesAreCached()) { 49 | return; 50 | } 51 | 52 | Route::middleware(['nova']) 53 | ->prefix('nova-vendor/nova-order-nestedset-field') 54 | ->group(__DIR__.'/../routes/api.php'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Traits/Orderable.php: -------------------------------------------------------------------------------- 1 | getPrevSibling(); 25 | if ($prevItem !== null) { 26 | /** @var NodeTrait $prevItem */ 27 | $this->insertBeforeNode($prevItem); 28 | } 29 | } 30 | 31 | /** 32 | * Move item to next position 33 | */ 34 | public function moveOrderDown(): void 35 | { 36 | $nextItem = $this->getNextSibling(); 37 | if ($nextItem !== null) { 38 | /** @var NodeTrait $nextItem */ 39 | $this->insertAfterNode($nextItem); 40 | } 41 | } 42 | 43 | public function buildSortQuery(): Builder 44 | { 45 | $query = static::query()->where($this->getParentIdName(), $this->getParentId()); 46 | if (! empty($this->getScopeAttributes())) { 47 | foreach ($this->getScopeAttributes() as $attributeName) { 48 | if (! empty($this->{$attributeName})) { 49 | $query->where($attributeName, $this->{$attributeName}); 50 | } 51 | } 52 | } 53 | 54 | return $query; 55 | } 56 | 57 | public function scopeOrdered(Builder $query, string $direction = 'asc'): Builder 58 | { 59 | return $query->orderBy($this->getLftName(), $direction); 60 | } 61 | 62 | public function getOrderableCachePrefix(): string 63 | { 64 | return sprintf('nova-order-nestedset-field.%s', md5(get_class($this).'-'.(int) $this->{$this->getParentIdName()})); 65 | } 66 | 67 | /** 68 | * Clear the cache corresponding to the model 69 | */ 70 | public function clearOrderableCache(): void 71 | { 72 | Cache::forget($this->getOrderableCachePrefix().'.first'); 73 | Cache::forget($this->getOrderableCachePrefix().'.last'); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix') 2 | 3 | require('./nova.mix') 4 | 5 | mix 6 | .setPublicPath('dist') 7 | .js('resources/js/field.js', 'js') 8 | .vue({ version: 3 }) 9 | .nova('novius/order-nestedset-field') 10 | --------------------------------------------------------------------------------