Merge branch 'issue-299-and-300' into 'develop'

feat ✨ : refactor code for ordering list of products or recipes

See merge request ldc-web/projets/le-gaulois-pro!235
This commit is contained in:
Noix 2026-04-09 06:56:13 +00:00
commit 4d0138d635
20 changed files with 367 additions and 16 deletions

View file

@ -7,9 +7,11 @@ use App\Enums\TagTypeEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Advice\StoreAdviceRequest;
use App\Http\Requests\Bo\Advice\UpdateAdviceRequest;
use App\Http\Requests\Bo\UpdateOrderRequest;
use App\Models\Advice;
use App\Models\Product;
use App\Models\Tag;
use App\Traits\Controllers\ManagesOrder;
use App\Traits\Controllers\UpdateModelPublished;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Http\Request;
@ -20,6 +22,7 @@ use Illuminate\Support\Str;
class AdviceController extends Controller
{
use UpdateModelPublished;
use ManagesOrder;
/**
* Display a listing of the resource.

View file

@ -7,6 +7,7 @@ use App\Http\Requests\Bo\BrigadeSections\UpdateBrigadeSectionsRequest;
use App\Models\BrigadeSection;
use App\Traits\Controllers\ChangesModelOrder;
use App\Traits\Controllers\UpdateModelPublished;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Uwil\Laravel\Common\Controllers\Controller;
@ -69,7 +70,7 @@ class BrigadeSectionController extends Controller
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\BrigadeSections\StoreBrigadeSectionRequest $request
* @param \App\Http\Requests\Bo\BrigadeSections\StoreBrigadeSectionsRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(StoreBrigadeSectionsRequest $request): \Illuminate\Http\RedirectResponse
@ -111,7 +112,7 @@ class BrigadeSectionController extends Controller
* @param \App\Models\BrigadeSection $brigadeSection
* @return \Illuminate\Http\RedirectResponse
*/
public function update(UpdateBrigadeSectionsRequest $request, BrigadeSection $brigadeSection): \Illuminate\Http\RedirectResponse
public function update(UpdateBrigadeSectionsRequest $request, BrigadeSection $brigadeSection): RedirectResponse
{
return DB::transaction(function () use ($request, $brigadeSection) {
$brigadeSection->fill($request->validated());

View file

@ -6,6 +6,7 @@ use App\Enums\TagTypeEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Products\StoreProductRequest;
use App\Http\Requests\Bo\Products\UpdateProductRequest;
use App\Http\Requests\Bo\UpdateOrderRequest;
use App\Lib\Helpers\FileStorageHelper;
use App\Models\Product;
use App\Models\Range;
@ -13,6 +14,7 @@ use App\Models\Subrange;
use App\Models\Tag;
use App\Traits\Controllers\UpdateModelPublished;
use App\Traits\Controllers\ChangesModelOrder;
use App\Traits\Controllers\ManagesOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@ -21,6 +23,7 @@ class ProductController extends Controller
{
use ChangesModelOrder;
use UpdateModelPublished;
use ManagesOrder;
/**
* Display a listing of the resource.

View file

@ -0,0 +1,72 @@
<?php
namespace App\Http\Requests\Bo;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
class UpdateOrderRequest extends FormRequest
{
/**
* Attribute model needed.
*
* @var Model $orderable
*/
private Model $orderable;
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth('backend')->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation(): void
{
$this->orderable = collect($this->route()->parameters())
->first(fn($param) => $param instanceof Model);
$groupField = $this->orderable->getOrderGroupField();
if ($groupField) {
$this->orderable::query()->where($groupField, $this->orderable->{$groupField});
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'priority_order' => [
'required',
'integer',
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
function ($attribute, $value, $fail) {
if ($this->orderable && intval($value) < 1) {
$fail(trans('validation.custom.min_priority'));
}
},
sprintf('max:%s', $this->orderable->max('priority_order')),
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
function ($attribute, $value, $fail) {
if ($this->orderable && intval($value) === $this->orderable->priority_order) {
$fail(trans('validation.custom.same_priority'));
}
},
],
];
}
}

View file

@ -6,6 +6,7 @@ use App\Enums\Advices\AdviceTypeEnum;
use App\Enums\Advices\AdviceDifficultyEnum;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use App\Traits\Models\Orderable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -46,6 +47,7 @@ use Uwil\Laravel\Redirections\Traits\IsRedirectable;
* @property integer|null $preparation_time Time of preparation.
* @property \App\Enums\Advices\AdviceDifficultyEnum|null $difficulty Difficulty.
* @property boolean $published Status.
* @property integer $priority_order Priority Order.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
* @property-read \Illuminate\Support\Carbon $updated_at Updated date.
*
@ -65,6 +67,7 @@ class Advice extends Authenticatable implements SeoInterface, Sitemapable, Redir
use HasFactory;
use SeoTrait;
use IsRedirectable;
use Orderable;
/**
* The attributes that are mass assignable.
@ -95,7 +98,8 @@ class Advice extends Authenticatable implements SeoInterface, Sitemapable, Redir
'cooking_time',
'preparation_time',
'difficulty',
'published'
'published',
'priority_order'
];
/**
@ -119,6 +123,7 @@ class Advice extends Authenticatable implements SeoInterface, Sitemapable, Redir
{
static::creating(function (self $model) {
self::manageFields($model);
self::bootOrderable($model);
});
static::updating(function (self $model) {
self::manageFields($model);
@ -133,6 +138,7 @@ class Advice extends Authenticatable implements SeoInterface, Sitemapable, Redir
FileStorageHelper::removeFile($model, 'picture');
FileStorageHelper::removeFile($model, 'sticker');
FileStorageHelper::removeFile($model, 'sticker_sm');
self::setOrder($model);
});
}
@ -375,4 +381,15 @@ class Advice extends Authenticatable implements SeoInterface, Sitemapable, Redir
$params = ['publishedAdvice' => $model];
return SitemapHelper::generateUrl($route, $params, $model);
}
/**
* Group ordering by type.
*
* @return string
*/
public function getOrderGroupField(): string
{
return 'type';
}
}

View file

@ -106,7 +106,7 @@ class BrigadeSection extends Model
* @param self $model
* @param boolean $isDeletion
* @return void
* @throws \Illuminate\Validation\ValidationException
* @throws \Illuminate\Validation\ValidationException Returns if fails.
*/
private static function assertLimitPublishedSections(self $model, bool $isDeletion = false)
{

View file

@ -13,6 +13,7 @@ use App\Lib\Helpers\FileStorageHelper;
use Illuminate\Database\Eloquent\Model;
use Spatie\Sitemap\Contracts\Sitemapable;
use App\Enums\Products\ProductSpeciesEnum;
use App\Traits\Models\Orderable;
use Uwil\SitemapGenerator\Lib\SitemapHelper;
use Uwil\Seo\Laravel\Interfaces\SeoInterface;
use Uwil\Laravel\Redirections\Traits\IsRedirectable;
@ -42,6 +43,7 @@ use Uwil\Laravel\Redirections\Interfaces\RedirectableInterface;
* @property boolean $is_egalim Is egalim or not.
* @property boolean $published Status.
* @property integer $order Order.
* @property integer $priority_order Priority Order.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
* @property-read \Illuminate\Support\Carbon $updated_at Updated date.
*
@ -56,6 +58,7 @@ class Product extends Model implements SeoInterface, Sitemapable, RedirectableIn
use HasFactory;
use SeoTrait;
use IsRedirectable;
use Orderable;
/**
* The attributes that are mass assignable.
@ -83,7 +86,8 @@ class Product extends Model implements SeoInterface, Sitemapable, RedirectableIn
'is_vip',
'is_egalim',
'order',
'published'
'published',
'priority_order',
];
/**
@ -113,7 +117,7 @@ class Product extends Model implements SeoInterface, Sitemapable, RedirectableIn
// * EVENT CLOSURES.
static::creating(function (self $model) {
self::manageFields($model);
$model->order = \intval(self::query()->max('order')) + 1;
self::bootOrderable($model);
});
static::updating(function (self $model) {
self::manageFields($model);
@ -131,6 +135,7 @@ class Product extends Model implements SeoInterface, Sitemapable, RedirectableIn
$model,
!is_array($model->getOriginal('slider_pictures')) ? [] : $model->getOriginal('slider_pictures'),
);
self::setOrder($model);
});
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Traits\Controllers;
use App\Http\Requests\Bo\UpdateOrderRequest;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\RedirectResponse;
trait ManagesOrder
{
/**
* Updates the order of a model and reorders all impacted elements.
*
* Shifts by +1 or -1 all elements located between the old
* and new position of the model, then assigns the new position
* to the target model. The whole operation runs inside a
* database transaction to ensure data consistency.
*
* @param UpdateOrderRequest $request The validated request containing the 'priority' field.
* @param Model $model The model to reorder (must use the Orderable trait).
* @return RedirectResponse
*
* @throws \Throwable Returns if save fail.
*/
public function changeOrderPriority(UpdateOrderRequest $request, Model $model): RedirectResponse
{
$newPriority = (int) $request->validated('priority_order');
return DB::transaction(function () use ($model, $newPriority): RedirectResponse {
$query = $model::query()
->whereKeyNot($model)
->whereBetween('priority_order', [
min($model->priority_order, $newPriority),
max($model->priority_order, $newPriority),
]);
$groupField = $model->getOrderGroupField();
if ($groupField) {
$query->where($groupField, $model->{$groupField});
}
$model->priority_order < $newPriority
? $query->decrement('priority_order', 1)
: $query->increment('priority_order', 1);
$model->priority_order = $newPriority;
$model->saveOrFail();
return redirect()->back()->with('success', trans('crud.messages.order_changed'));
});
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Traits\Models;
use Illuminate\Database\Eloquent\Model;
trait Orderable
{
/**
* Returns the grouping field used for ordering.
* Returns null if the order is global
*
* @return string|null
*/
public function getOrderGroupField(): ?string
{
return null;
}
/**
* shifts down by 1 all elements positioned after the delete.
*
* @param Model $model
* @return void
*/
public static function setOrder(Model $model): void
{
$groupField = $model->getOrderGroupField();
$query = $model::query()->where('priority_order', '>', $model->priority_order);
if ($groupField) {
$query->where($groupField, $model->{$groupField});
}
$query->each(function (Model $item): void {
$item->priority_order -= 1;
$item->saveOrFail();
});
}
/**
* Assigns the next order value on creation.
*
* @return void
*/
public static function bootOrderable(): void
{
static::creating(function (Model $model): void {
if (!$model->priority_order) {
$groupField = $model->getOrderGroupField();
$query = static::query();
if ($groupField) {
$query->where($groupField, $model->{$groupField});
}
$model->priority_order = ($query->max('priority_order') ?? 0) + 1;
}
});
}
}

View file

@ -44,7 +44,8 @@ class SlideFactory extends Factory
'picture_alt' => $picture_alt,
'picture_title' => $picture_title,
'picture_thumbnail_alt' => $picture_alt,
'picture_thumbnail_title' => $picture_title
'picture_thumbnail_title' => $picture_title,
'published' => $this->faker->boolean(),
];
}
}

View file

@ -0,0 +1,38 @@
<?php
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->unsignedInteger('priority_order')->comment('Ordre de priorite')->default(0)->after('order');
});
$order = 1;
Product::query()
->orderBy('order')
->each(function (Product $product) use (&$order) {
$product->priority_order = $order++;
$product->saveOrFail();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('priority_order');
});
}
};

View file

@ -0,0 +1,43 @@
<?php
use App\Enums\Advices\AdviceTypeEnum;
use App\Models\Advice;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('advice', function (Blueprint $table) {
$table->unsignedInteger('priority_order')->comment('Ordre de priorite')->default(0)->after('published');
});
// loop through all types and in each type we order
Advice::query()->select('type')->pluck('type')
->each(function (AdviceTypeEnum $type) {
$order = 1;
Advice::query()
->where('type', $type)
->orderBy('created_at')
->each(function (Advice $advice) use (&$order) {
$advice->priority_order = $order++;
$advice->saveOrFail();
});
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('advice', function (Blueprint $table) {
$table->dropColumn('priority_order');
});
}
};

View file

@ -21,7 +21,7 @@ class ExpertiseProductSeeder extends Seeder
ExpertiseProduct::factory(rand(0, 3))->make()->each(
// @phpstan-ignore-next-line
function (ExpertiseProduct $expertiseProduct) use ($enum) {
$expertiseProduct->page = $enum;
$expertiseProduct->page = ExpertiseProductPageEnum::make($enum->value);
$expertiseProduct->saveQuietly();
}
);

View file

@ -141,5 +141,10 @@ return [
'seo_title' => 'SEO title',
'seo_description' => 'SEO description',
'type' => 'type',
'new_priority' => 'New priority',
'priority' => 'priority',
'same_priority' => 'the priority value must be different from the current value.',
'min_priority' => 'the priority value must be greater than 0.'
],
];

View file

@ -151,7 +151,11 @@ return [
'seo_title' => 'titre SEO',
'seo_description' => 'description SEO',
'type' => 'type',
'url' => 'Le champ :field doit être une URL valide.'
'url' => 'Le champ :field doit être une URL valide.',
'new_priority' => 'Nouvelle priorité',
'priority' => 'priorité',
'same_priority' => 'La valeur de priorité doit être différente de la valeur actuelle.',
'min_priority' => 'La valeur de priorité doit être supérieure à 0.'
],
'attributes' => [
'advantages' => 'Avantages',

View file

@ -37,6 +37,7 @@ import {
faTrash,
faUndoAlt,
faWandMagic,
faChevronDown
} from "@fortawesome/free-solid-svg-icons";
config.autoReplaceSvg = false;
@ -72,7 +73,7 @@ library.add(faListUl);
library.add(faCircleCheck);
library.add(faCircleExclamation);
library.add(faCircleInfo);
library.add(faChevronDown);
// Front
library.add(faChevronLeft);
library.add(faChevronRight);

View file

@ -0,0 +1,21 @@
<td class="text-center align-middle">
<div class="d-flex justify-content-center align-items-center">
<b class="mx-3">{{ $value ?? '' }}</b>
<div class="dropdown">
<button class="btn btn-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-chevron-down"></i>
</button>
<div class="dropdown-menu">
<form class="px-4 py-3" action="{{ $route }}" method="POST" novalidate>
@csrf
@method('PATCH')
<div class="mb-3">
<label for="priority-{{ $model->getKey() }}" class="form-label">{{ __('validation.custom.new_priority') }}</label>
<input type="number" class="form-input" id="priority-{{ $model->getKey() }}" placeholder="1" name="priority_order" min="1">
</div>
<button type="submit" class="btn btn-primary">{{ _('Sauvegarder') }}</button>
</form>
</div>
</div>
</div>
</td>

View file

@ -22,6 +22,7 @@
<thead>
@include('bo.modules.table-col-sorter', [
'cols' => [
'priority_order' => Str::ucfirst(__('validation.custom.priority')),
'title' => __('validation.attributes.title'),
'picture' => __('validation.attributes.picture'),
'published' => __('validation.attributes.published'),
@ -32,6 +33,11 @@
<tbody>
@foreach ($adviceModels as $adviceModel)
<tr class="border-bottom">
@include('bo.modules.change-model-order-select', [
'model' => $adviceModel,
'value' => $adviceModel->priority_order,
'route' => route('bo.advice.change-priority', $adviceModel)
])
<td class="text-center align-middle">{{ $adviceModel->title }}</td>
<td class="text-center align-middle">
@if($adviceModel->picture)
@ -46,10 +52,8 @@
'model' => $adviceModel,
])
<td class="text-center align-middle"><span class="badge bg-secondary">{{ $adviceModel->updated_at }}</span></td>
@php
$routeName = request()->route()->getName();
@endphp
@if (empty(request()->search) && Session::get("$routeName.sort_col") === 'order')
@if (empty(request()->search) && request()->range)
@php $routeName = request()->route()->getName(); @endphp
@include('bo.modules.change-model-order', [
'routeName' => 'advice',
'models' => $adviceModels,

View file

@ -33,6 +33,7 @@
$cols = [];
$routeName = request()->route()->getName();
$cols = array_merge($cols, [
'priority_order' => Str::ucfirst(__('validation.custom.priority')),
'label' => Str::ucfirst(__('validation.attributes.label')),
'species' => Str::ucfirst(__('validation.attributes.species')),
'picture' => Str::ucfirst(__('validation.attributes.picture')),
@ -52,6 +53,11 @@
<tbody>
@foreach ($productModels as $productModel)
<tr class="border-bottom">
@include('bo.modules.change-model-order-select', [
'model' => $productModel,
'value' => $productModel->priority_order,
'route' => route('bo.products.change-priority', $productModel)
])
<td class="text-center align-middle">{{ $productModel->label }}</td>
<td class="text-center align-middle">{{ $productModel->species->label() }}</td>
<td class="text-center align-middle">

View file

@ -61,6 +61,12 @@ Route::prefix('bo')
])->where('direction', 'up|down')->name('products.change-order');
Route::post('/products/{product}/change-published', [ProductController::class, 'changePublished'])
->name('products.change-published');
Route::patch('/products/{product}/change-priority', [ProductController::class, 'changeOrderPriority'])
->name('products.change-priority');
Route::patch('/products/{product}/change-order/{direction}', [
BrigadeSectionController::class,
'changeOrder'
])->where('direction', 'up|down')->name('products.change-order');
// * NEWS
Route::resource('news', NewsController::class)->except(['show'])
@ -83,6 +89,8 @@ Route::prefix('bo')
Route::get('advice/{type}/create', [AdviceController::class, 'create'])->name('advice.create');
Route::post('/advice/{advice}/change-published', [AdviceController::class, 'changePublished'])
->name('advice.change-published');
Route::patch('/advice/{advice}/change-priority', [AdviceController::class, 'changeOrderPriority'])
->name('advice.change-priority');
// * EXPERTISE PRODUITS VIP
Route::resource('vips', ExpertiseProductController::class)->except(['show']);
@ -95,7 +103,10 @@ Route::prefix('bo')
Route::resource('brigade_sections', BrigadeSectionController::class)->except(['show'])
->withoutMiddleware('htmlsanitize')
->middleware('htmlsanitize:content');
Route::post('/brigade_sections/{brigade_section}/change-published', [BrigadeSectionController::class, 'changePublished'])
Route::post(
'/brigade_sections/{brigade_section}/change-published',
[BrigadeSectionController::class, 'changePublished']
)
->name('brigade_sections.change-published');
Route::patch('/brigade_sections/{brigade_section}/change-order/{direction}', [
BrigadeSectionController::class,