diff --git a/README.md b/README.md index 3e5078c..3ab1131 100644 --- a/README.md +++ b/README.md @@ -48,32 +48,46 @@ Optionally you can publish the config file with: php artisan vendor:publish --tag=eloquent-sortable-config ``` -This is the content of the file that will be published in `config/eloquent-sortable.php` +This is the content of the file that will be published in `config/eloquent-sortable.php`: ```php return [ - /* - * The name of the column that will be used to sort models. - */ - 'order_column_name' => 'order_column', - - /* - * Define if the models should sort when creating. When true, the package - * will automatically assign the highest order number to a new model - */ - 'sort_when_creating' => true, - - /* - * Define if the timestamps should be ignored when sorting. - * When true, updated_at will not be updated when using setNewOrder - */ - 'ignore_timestamps' => false, + /* + * The name of the column that will be used to sort models. + */ + 'order_column_name' => 'order_column', + + /* + * Define if the models should sort when creating. + * When true, the package will automatically assign the highest order number to a new model. + */ + 'sort_when_creating' => true, + + /* + * Define if the models should sort when updating. + * When true, the package will automatically update the order of models when one is updated. + */ + 'sort_when_updating' => true, + + /* + * Define if the models should sort when deleting. + * When true, the package will automatically update the order of models when one is deleted. + */ + 'sort_when_deleting' => true, + + /* + * Define if the timestamps should be ignored when sorting. + * When true, `updated_at` will not be updated when using `setNewOrder`, `setMassNewOrder`, + * or when models are reordered automatically during creation, updating, or deleting. + */ + 'ignore_timestamps' => false, ]; ``` ## Usage To add sortable behaviour to your model you must: + 1. Implement the `Spatie\EloquentSortable\Sortable` interface. 2. Use the trait `Spatie\EloquentSortable\SortableTrait`. 3. Optionally specify which column will be used as the order column. The default is `order_column`. @@ -91,6 +105,8 @@ class MyModel extends Model implements Sortable public $sortable = [ 'order_column_name' => 'order_column', 'sort_when_creating' => true, + 'sort_when_updating' => true, + 'sort_when_deleting' => true, ]; // ... @@ -113,12 +129,24 @@ $myModel->save(); // order_column for this record will be set to 2 $myModel = new MyModel(); $myModel->save(); // order_column for this record will be set to 3 - -//the trait also provides the ordered query scope +// The trait also provides the ordered query scope $orderedRecords = MyModel::ordered()->get(); ``` -You can set a new order for all the records using the `setNewOrder`-method +### New Sorting Methods + +#### Mass Update Ordering + +You can set a new order for all the records using the `setMassNewOrder`-method: + +```php +$newOrder = [5, 3, 1, 4, 2]; +MyModel::setMassNewOrder($newOrder); +``` + +This will reorder the records in the order specified by `$newOrder`. + +#### Setting a New Order for All Records ```php /** @@ -126,10 +154,10 @@ You can set a new order for all the records using the `setNewOrder`-method * the record for model id 1 will have order_column value 2 * the record for model id 2 will have order_column value 3 */ -MyModel::setNewOrder([3,1,2]); +MyModel::setNewOrder([3, 1, 2]); ``` -Optionally you can pass the starting order number as the second argument. +Optionally, you can pass the starting order number as the second argument. ```php /** @@ -137,7 +165,7 @@ Optionally you can pass the starting order number as the second argument. * the record for model id 1 will have order_column value 12 * the record for model id 2 will have order_column value 13 */ -MyModel::setNewOrder([3,1,2], 10); +MyModel::setNewOrder([3, 1, 2], 10); ``` You can modify the query that will be executed by passing a closure as the fourth argument. @@ -148,11 +176,12 @@ You can modify the query that will be executed by passing a closure as the fourt * the record for model id 1 will have order_column value 12 * the record for model id 2 will have order_column value 13 */ -MyModel::setNewOrder([3,1,2], 10, null, function($query) { +MyModel::setNewOrder([3, 1, 2], 10, null, function ($query) { $query->withoutGlobalScope(new ActiveScope); }); ``` +#### Setting New Order by Custom Column To sort using a column other than the primary key, use the `setNewOrderByCustomColumn`-method. @@ -184,6 +213,8 @@ MyModel::setNewOrderByCustomColumn('uuid', [ ], 10); ``` +### Additional Methods for Sorting + You can also move a model up or down with these methods: ```php @@ -211,6 +242,36 @@ You can swap the order of two models: MyModel::swapOrder($myModel, $anotherModel); ``` +### Handling Model Updates and Deletions + +If you want your model to automatically reorder upon updating or deleting a record, ensure the relevant configuration values (`sort_when_updating`, `sort_when_deleting`) are set to `true` in the configuration file. This will allow your models to maintain the correct order without needing to manually update the ordering each time a change is made. + +For example, if `sort_when_updating` is set to `true`, any changes to a model's attributes will automatically adjust the order, ensuring consistency. + +In addition to automatic reordering, you can also manually trigger sorting for specific scenarios. Here is an example of how you can manually trigger sorting: + +```php +$model = $this->model::query()->find(1); +$model->forceFill(['updated_at' => now()]); +$model->sortables = [1, 2, 3]; // The `sortables` array contains the IDs of the records that need to be reordered. +$model->save(); +``` + +In this scenario, the model is being updated with a new order, and the `sortables` property is set before saving. This ensures the correct order is applied manually when necessary. + +#### Sorting When Deleting + +If `sort_when_deleting` is enabled, the order of the remaining models will be automatically adjusted when a model is deleted. For example: + +```php +$model = MyModel::find(1); +$model->delete(); // The remaining records will be reordered automatically. +``` + +This helps maintain the correct sequence without any manual intervention. + +In this scenario, the model is being updated with a new order, and the `sortables` property is set before saving. This ensures the correct order is applied manually when necessary. + ### Grouping If your model/table has a grouping field (usually a foreign key): `id, `**`user_id`**`, title, order_column` diff --git a/config/eloquent-sortable.php b/config/eloquent-sortable.php index f05614e..5f1191b 100644 --- a/config/eloquent-sortable.php +++ b/config/eloquent-sortable.php @@ -1,5 +1,7 @@ true, + /* + * Define if the models should sort when updating. + * When true, the package will automatically update the order of models when one is updated. + */ + 'sort_when_updating' => true, + + /* + * Define if the models should sort when deleting. + * When true, the package will automatically update the order of models when one is deleted. + */ + 'sort_when_deleting' => true, + /* * Define if the timestamps should be ignored when sorting. - * When true, updated_at will not be updated when using setNewOrder + * When true, `updated_at` will not be updated when using `setNewOrder`, `setMassNewOrder`, + * or when models are reordered automatically during creation, updating, or deleting. */ 'ignore_timestamps' => false, ]; diff --git a/src/EloquentModelSortedEvent.php b/src/EloquentModelSortedEvent.php index 3c78bec..d86e5a2 100644 --- a/src/EloquentModelSortedEvent.php +++ b/src/EloquentModelSortedEvent.php @@ -1,5 +1,7 @@ shouldSortWhenCreating()) { $model->setHighestOrderNumber(); } }); + + static::updating(function (Model $model): void { + if ($model instanceof Sortable && $model->shouldSortWhenUpdating() && !empty($model->sortables)) { + self::setMassNewOrder($model->sortables); + } + }); + + static::deleting(function (Model $model): void { + if ($model instanceof Sortable && $model->shouldSortWhenDeleting() && !empty($model->sortables)) { + self::setMassNewOrder($model->sortables); + } + }); } public function setHighestOrderNumber(): void @@ -36,7 +54,7 @@ public function getLowestOrderNumber(): int return (int)$this->buildSortQuery()->min($this->determineOrderColumnName()); } - public function scopeOrdered(Builder $query, string $direction = 'asc') + public function scopeOrdered(Builder $query, string $direction = 'asc'): Builder { return $query->orderBy($this->determineOrderColumnName(), $direction); } @@ -47,7 +65,7 @@ public static function setNewOrder( string $primaryKeyColumn = null, callable $modifyQuery = null ): void { - if (! is_array($ids) && ! $ids instanceof ArrayAccess) { + if (!is_array($ids) && !$ids instanceof ArrayAccess) { throw new InvalidArgumentException('You must pass an array or ArrayAccess object to setNewOrder'); } @@ -79,6 +97,62 @@ public static function setNewOrder( } } + public static function setMassNewOrder( + array $getSortables, + int $incrementOrder = 1, + ?string $primaryKeyColumn = null + ): void { + $model = new static(); + $orderColumnName = $model->determineOrderColumnName(); + $ignoreTimestamps = config('eloquent-sortable.ignore_timestamps', false); + + if (is_null($primaryKeyColumn)) { + $primaryKeyColumn = $model->getQualifiedKeyName(); + } + + if ($ignoreTimestamps) { + static::$ignoreTimestampsOn = array_values(array_merge(static::$ignoreTimestampsOn, [static::class])); + } + + $caseStatement = collect($getSortables)->reduce(function (string $carry, int $id) use (&$incrementOrder) { + $incrementOrder++; + $carry .= "WHEN {$id} THEN {$incrementOrder} "; + return $carry; + }, ''); + + $getSortablesId = implode(', ', $getSortables); + + DB::transaction( + function () use ( + $model, + $primaryKeyColumn, + $orderColumnName, + $caseStatement, + $getSortablesId, + $ignoreTimestamps + ) { + $timestampUpdate = $ignoreTimestamps ? '' : ", `updated_at` = NOW()"; + + DB::update( + " + UPDATE {$model->getTable()} + SET `{$orderColumnName}` = CASE {$primaryKeyColumn} + {$caseStatement} + END + {$timestampUpdate} + WHERE {$primaryKeyColumn} IN ({$getSortablesId}) + " + ); + } + ); + + Event::dispatch(new EloquentModelSortedEvent(static::class)); + + if ($ignoreTimestamps) { + static::$ignoreTimestampsOn = array_values(array_diff(static::$ignoreTimestampsOn, [static::class])); + } + } + public static function setNewOrderByCustomColumn(string $primaryKeyColumn, $ids, int $startOrder = 1) { self::setNewOrder($ids, $startOrder, $primaryKeyColumn); @@ -97,6 +171,16 @@ public function shouldSortWhenCreating(): bool return $this->sortable['sort_when_creating'] ?? config('eloquent-sortable.sort_when_creating', true); } + public function shouldSortWhenUpdating(): bool + { + return $this->sortable['sort_when_updating'] ?? config('eloquent-sortable.sort_when_updating', true); + } + + public function shouldSortWhenDeleting(): bool + { + return $this->sortable['sort_when_deleting'] ?? config('eloquent-sortable.sort_when_deleting', true); + } + public function moveOrderDown(): static { $orderColumnName = $this->determineOrderColumnName(); @@ -106,7 +190,7 @@ public function moveOrderDown(): static ->where($orderColumnName, '>', $this->$orderColumnName) ->first(); - if (! $swapWithModel) { + if (!$swapWithModel) { return $this; } @@ -122,7 +206,7 @@ public function moveOrderUp(): static ->where($orderColumnName, '<', $this->$orderColumnName) ->first(); - if (! $swapWithModel) { + if (!$swapWithModel) { return $this; } diff --git a/tests/Dummy.php b/tests/Dummy.php index 19e9da0..e56cd7e 100644 --- a/tests/Dummy.php +++ b/tests/Dummy.php @@ -1,5 +1,7 @@ delete(); - $this->assertEquals(DummyWithSoftDeletes::withTrashed()->count(), (new DummyWithSoftDeletes())->getHighestOrderNumber()); + $this->assertEquals( + DummyWithSoftDeletes::withTrashed()->count(), + (new DummyWithSoftDeletes())->getHighestOrderNumber() + ); } /** @test */ public function it_can_set_a_new_order() { - Event::fake(EloquentModelSortedEvent::class); $newOrder = Collection::make(Dummy::all()->pluck('id'))->shuffle()->toArray(); @@ -391,9 +393,9 @@ public function it_can_move_a_model_to_the_last_place() public function it_can_use_config_properties() { config([ - 'eloquent-sortable.order_column_name' => 'order_column', - 'eloquent-sortable.sort_when_creating' => true, - ]); + 'eloquent-sortable.order_column_name' => 'order_column', + 'eloquent-sortable.sort_when_creating' => true, + ]); $model = new class () extends Dummy { public $sortable = []; @@ -408,9 +410,9 @@ public function it_can_override_config_properties() { $model = new class () extends Dummy { public $sortable = [ - 'order_column_name' => 'my_custom_order_column', - 'sort_when_creating' => false, - ]; + 'order_column_name' => 'my_custom_order_column', + 'sort_when_creating' => false, + ]; }; $this->assertEquals($model->determineOrderColumnName(), 'my_custom_order_column'); @@ -432,4 +434,150 @@ public function it_can_tell_if_element_is_last_in_order() $this->assertTrue($model[$model->count() - 1]->isLastInOrder()); $this->assertFalse($model[$model->count() - 2]->isLastInOrder()); } + + /** @test */ + public function it_sets_mass_new_order_correctly() + { + $newOrder = Collection::make(Dummy::all()->pluck('id'))->shuffle()->toArray(); + + Dummy::setMassNewOrder($newOrder); + + foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { + $this->assertEquals($newOrder[$i], $dummy->id); + } + } + + /** @test */ + public function it_updates_order_when_sortables_property_is_set() + { + $model = Dummy::first(); + $originalOrder = Dummy::pluck('order_column', 'id'); + + // Shuffle order and set it on the model as sortables + $newOrder = $originalOrder->keys()->shuffle()->toArray(); + $model->sortables = $newOrder; + + $model->save(); + + foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { + $this->assertEquals($newOrder[$i], $dummy->id); + } + } + + /** @test */ + public function it_does_not_update_order_when_sortables_is_not_set_on_update() + { + $model = Dummy::first(); + $originalOrder = Dummy::pluck('order_column', 'id'); + + // Do not provide sortables to the model + $model->name = 'Updated Name'; + $model->save(); + + foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { + $this->assertEquals($originalOrder[$i], $dummy->id); + } + } + + /** @test */ + public function it_updates_order_when_sortables_property_is_set_on_delete() + { + $modelToDelete = Dummy::first(); + $remainingModels = Dummy::where('id', '!=', $modelToDelete->id)->pluck('id'); + + $newOrder = $remainingModels->shuffle()->toArray(); + $modelToDelete->sortables = $newOrder; + + $modelToDelete->delete(); + + foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { + $this->assertEquals($newOrder[$i], $dummy->id); + } + } + + /** @test */ + public function it_does_not_update_order_when_sortables_is_not_set_on_delete() + { + $modelToDelete = Dummy::first(); + $remainingModels = Dummy::where('id', '!=', $modelToDelete->id)->pluck('id'); + + $originalOrder = $remainingModels->values()->toArray(); + + // Do not provide sortables to the model before deleting + $modelToDelete->delete(); + + foreach (Dummy::orderBy('order_column')->get() as $i => $dummy) { + $this->assertEquals($originalOrder[$i], $dummy->id); + } + } + + /** @test */ + public function it_dispatches_sorted_event_on_mass_update_for_sortables() + { + Event::fake(EloquentModelSortedEvent::class); + + $newOrder = Collection::make(Dummy::all()->pluck('id'))->shuffle()->toArray(); + Dummy::setMassNewOrder($newOrder); + + Event::assertDispatched(EloquentModelSortedEvent::class, function (EloquentModelSortedEvent $event) { + return $event->isFor(Dummy::class); + }); + } + + /** @test */ + public function it_respects_ignore_timestamps_on_mass_update_for_sortables() + { + $this->setUpTimestamps(); + DummyWithTimestamps::query()->update(['updated_at' => now()]); + $originalTimestamps = DummyWithTimestamps::all()->pluck('updated_at'); + + // Update with timestamps enabled + config()->set('eloquent-sortable.ignore_timestamps', false); + $newOrder = Collection::make(DummyWithTimestamps::all()->pluck('id'))->shuffle()->toArray(); + DummyWithTimestamps::setMassNewOrder($newOrder); + + foreach (DummyWithTimestamps::orderBy('order_column')->get() as $i => $dummy) { + $this->assertNotEquals($originalTimestamps[$i], $dummy->updated_at); + } + + // Update with timestamps disabled + config()->set('eloquent-sortable.ignore_timestamps', true); + DummyWithTimestamps::setMassNewOrder($newOrder); + + foreach (DummyWithTimestamps::orderBy('order_column')->get() as $i => $dummy) { + $this->assertEquals($originalTimestamps[$i], $dummy->updated_at); + } + } + + /** @test */ + public function it_respects_sort_when_updating_setting() + { + $model = new class () extends Dummy { + public $sortable = ['sort_when_updating' => true]; + }; + + $this->assertTrue($model->shouldSortWhenUpdating()); + + $model = new class () extends Dummy { + public $sortable = ['sort_when_updating' => false]; + }; + + $this->assertFalse($model->shouldSortWhenUpdating()); + } + + /** @test */ + public function it_respects_sort_when_deleting_setting() + { + $model = new class () extends Dummy { + public $sortable = ['sort_when_deleting' => true]; + }; + + $this->assertTrue($model->shouldSortWhenDeleting()); + + $model = new class () extends Dummy { + public $sortable = ['sort_when_deleting' => false]; + }; + + $this->assertFalse($model->shouldSortWhenDeleting()); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index a606fd0..8991da7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,5 +1,7 @@