├── .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 | [](https://travis-ci.org/novius/laravel-nova-order-nestedset-field)
3 | [](https://packagist.org/packages/novius/laravel-nova-order-nestedset-field)
4 | [](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 |
2 |
3 |
15 |
27 |
28 |
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 |
--------------------------------------------------------------------------------