diff --git a/app/Http/Controllers/Admin/AdminContentController.php b/app/Http/Controllers/Admin/AdminContentController.php index 0d008cbc4..2aeb215e4 100644 --- a/app/Http/Controllers/Admin/AdminContentController.php +++ b/app/Http/Controllers/Admin/AdminContentController.php @@ -11,6 +11,8 @@ use Illuminate\Foundation\Application; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Redirector; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\View\View; class AdminContentController extends BasePageController @@ -24,12 +26,23 @@ class AdminContentController extends BasePageController { $this->setAdminPrefs(); - $contentList = Content::query() - ->orderByRaw('contenttype, COALESCE(ordinal, 1000000)') - ->get(); + $contentList = Content::query()->ordered()->get(); + $contentGroups = $contentList + ->groupBy('contenttype') + ->sortKeys() + ->map(function ($items, $contentType) { + /** @var Collection $items */ + return [ + 'contenttype' => (int) $contentType, + 'label' => $items->first()?->getContentTypeLabel() ?? 'Unknown', + 'items' => $items, + ]; + }) + ->values(); $this->viewData = array_merge($this->viewData, [ 'contentlist' => $contentList, + 'contentGroups' => $contentGroups, 'meta_title' => 'Content List', 'title' => 'Content List', ]); @@ -154,6 +167,63 @@ class AdminContentController extends BasePageController ], 404); } + /** + * Persist a new global content order. + */ + public function reorder(Request $request): mixed + { + $validated = $request->validate([ + 'contenttype' => ['required', 'integer'], + 'ordered_ids' => ['required', 'array', 'min:1'], + 'ordered_ids.*' => ['integer', 'distinct', 'exists:content,id'], + ]); + + $contentType = (int) $validated['contenttype']; + $orderedIds = array_map('intval', $validated['ordered_ids']); + $expectedIds = Content::query() + ->where('contenttype', $contentType) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + + if ( + count($orderedIds) !== count($expectedIds) + || array_diff($orderedIds, $expectedIds) !== [] + || array_diff($expectedIds, $orderedIds) !== [] + ) { + return response()->json([ + 'success' => false, + 'message' => 'Reorder payload must include every content row for the selected group.', + ], 422); + } + + $timestamp = now(); + $ordinals = []; + + DB::transaction(function () use ($contentType, $orderedIds, $timestamp, &$ordinals): void { + foreach ($orderedIds as $index => $contentId) { + $ordinal = $index + 1; + + Content::query() + ->whereKey($contentId) + ->where('contenttype', $contentType) + ->update([ + 'ordinal' => $ordinal, + 'updated_at' => $timestamp, + ]); + + $ordinals[$contentId] = $ordinal; + } + }); + + return response()->json([ + 'success' => true, + 'message' => 'Content order updated successfully', + 'contenttype' => $contentType, + 'ordinals' => $ordinals, + ]); + } + /** * Delete content by ID. */ @@ -200,11 +270,6 @@ class AdminContentController extends BasePageController // Normalize URL $data = $this->normalizeContentUrl($data); - // If ordinal is 1, increment all existing ordinals - if (($data['ordinal'] ?? 0) === 1) { - Content::query()->where('ordinal', '>', 0)->increment('ordinal'); - } - return Content::query()->insertGetId([ 'role' => $data['role'] ?? Content::ROLE_EVERYONE, 'title' => $data['title'] ?? '', @@ -214,7 +279,7 @@ class AdminContentController extends BasePageController 'metakeywords' => $data['metakeywords'] ?? '', 'contenttype' => $data['contenttype'] ?? Content::TYPE_USEFUL, 'status' => $data['status'] ?? Content::STATUS_ENABLED, - 'ordinal' => $data['ordinal'] ?? 0, + 'ordinal' => $this->nextBottomOrdinal((int) ($data['contenttype'] ?? Content::TYPE_USEFUL)), 'created_at' => now(), 'updated_at' => now(), ]); @@ -230,8 +295,14 @@ class AdminContentController extends BasePageController // Normalize URL $data = $this->normalizeContentUrl($data); + $content = Content::query()->findOrFail($data['id']); + $updatedContentType = (int) ($data['contenttype'] ?? $content->contenttype); + $ordinal = $content->contenttype === $updatedContentType + ? $content->ordinal + : $this->nextBottomOrdinal($updatedContentType); + return Content::query() - ->where('id', $data['id']) + ->whereKey($content->id) ->update([ 'role' => $data['role'] ?? Content::ROLE_EVERYONE, 'title' => $data['title'] ?? '', @@ -239,9 +310,9 @@ class AdminContentController extends BasePageController 'body' => $data['body'] ?? '', 'metadescription' => $data['metadescription'] ?? '', 'metakeywords' => $data['metakeywords'] ?? '', - 'contenttype' => $data['contenttype'] ?? Content::TYPE_USEFUL, + 'contenttype' => $updatedContentType, 'status' => $data['status'] ?? Content::STATUS_ENABLED, - 'ordinal' => $data['ordinal'] ?? 0, + 'ordinal' => $ordinal, 'updated_at' => now(), ]); } @@ -286,4 +357,20 @@ class AdminContentController extends BasePageController return $data; } + + /** + * Get the ordinal that places new content at the bottom of the list. + */ + protected function nextBottomOrdinal(int $contentType): int + { + $maximumOrdinal = Content::query() + ->where('contenttype', $contentType) + ->max('ordinal'); + + if ($maximumOrdinal === null) { + return 1; + } + + return (int) $maximumOrdinal + 1; + } } diff --git a/app/Http/Controllers/ContentController.php b/app/Http/Controllers/ContentController.php index 839ac091a..28a00b1a7 100644 --- a/app/Http/Controllers/ContentController.php +++ b/app/Http/Controllers/ContentController.php @@ -89,9 +89,7 @@ class ContentController extends BasePageController */ protected function getActiveContent(): Collection // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType { - return Content::active() - ->orderByRaw('contenttype, COALESCE(ordinal, 1000000)') - ->get(); + return Content::active()->ordered()->get(); // @phpstan-ignore method.notFound } /** @@ -103,7 +101,7 @@ class ContentController extends BasePageController { return Content::query() ->where('id', '<>', 1) - ->orderByRaw('contenttype, COALESCE(ordinal, 1000000)') + ->ordered() // @phpstan-ignore method.notFound ->get(); } @@ -133,6 +131,9 @@ class ContentController extends BasePageController */ protected function getIndexContent(): ?Content { - return Content::active()->ofType(Content::TYPE_INDEX)->first(); + return Content::active() + ->ofType(Content::TYPE_INDEX) + ->ordered() // @phpstan-ignore method.notFound + ->first(); } } diff --git a/app/Models/Content.php b/app/Models/Content.php index 012220adb..630d43a29 100644 --- a/app/Models/Content.php +++ b/app/Models/Content.php @@ -35,6 +35,7 @@ use Illuminate\Database\Eloquent\Model; * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content ofType($type) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content forRole($role) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content frontPage() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Content ordered() * * @mixin \Eloquent * @@ -121,6 +122,16 @@ class Content extends Model }); } + /** + * Scope: Order content by type and ordinal within each type. + */ + public function scopeOrdered(Builder $query): Builder // @phpstan-ignore missingType.generics + { + return $query + ->orderBy('contenttype') + ->orderByRaw('COALESCE(ordinal, 1000000) ASC, id ASC'); + } + /** * Scope: Get front page content. */ @@ -128,7 +139,7 @@ class Content extends Model { return $query->active() // @phpstan-ignore method.notFound ->ofType(self::TYPE_INDEX) - ->orderByRaw('ordinal ASC, COALESCE(ordinal, 1000000), id'); + ->ordered(); // @phpstan-ignore method.notFound } /** diff --git a/app/View/Composers/GlobalDataComposer.php b/app/View/Composers/GlobalDataComposer.php index 8cc1c5e6b..7a41e6b5c 100644 --- a/app/View/Composers/GlobalDataComposer.php +++ b/app/View/Composers/GlobalDataComposer.php @@ -64,7 +64,7 @@ class GlobalDataComposer $viewData['usefulLinks'] = Cache::remember('content_useful_links', self::CACHE_TTL, function () { return Content::active() ->ofType(Content::TYPE_USEFUL) - ->orderBy('ordinal') + ->ordered() // @phpstan-ignore method.notFound ->get(); }); diff --git a/resources/js/alpine/components/content-toggle.js b/resources/js/alpine/components/content-toggle.js index f33edbf16..89c219889 100644 --- a/resources/js/alpine/components/content-toggle.js +++ b/resources/js/alpine/components/content-toggle.js @@ -4,12 +4,212 @@ */ import Alpine from '@alpinejs/csp'; -function escapeHtml(text) { - const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; - return String(text).replace(/[&<>"']/g, m => map[m]); -} - Alpine.data('contentToggle', () => ({ + dragSourceRow: null, + dragSourceBody: null, + dragSourceContentType: null, + originalOrder: [], + dropCompleted: false, + isSavingOrder: false, + + init() { + this.$nextTick(() => this.initializeDragAndDrop()); + }, + + getContentGroups() { + return Array.from(this.$root.querySelectorAll('[data-content-group]')); + }, + + initializeDragAndDrop() { + this.getContentGroups().forEach(group => { + const tableBody = group.querySelector('tbody'); + + if (!tableBody || tableBody.dataset.dragInitialized === 'true') { + return; + } + + tableBody.dataset.dragInitialized = 'true'; + tableBody.addEventListener('dragstart', event => this.onDragStart(event)); + tableBody.addEventListener('dragover', event => this.onDragOver(event)); + tableBody.addEventListener('dragenter', event => { + if (this.dragSourceRow && event.currentTarget === this.dragSourceBody) { + event.preventDefault(); + } + }); + tableBody.addEventListener('drop', event => this.onDrop(event)); + tableBody.addEventListener('dragend', () => this.onDragEnd()); + }); + }, + + onDragStart(event) { + const handle = event.target.closest('[data-drag-handle]'); + const row = handle?.closest('tr[data-content-id]'); + + if (!handle || !row || this.isSavingOrder) { + event.preventDefault(); + return; + } + + const tableBody = row.closest('tbody'); + + this.dragSourceRow = row; + this.dragSourceBody = tableBody; + this.dragSourceContentType = row.dataset.contentType || null; + this.originalOrder = this.getOrderedIds(tableBody); + this.dropCompleted = false; + + row.classList.add('opacity-60', 'is-dragging'); + handle.classList.remove('cursor-grab'); + handle.classList.add('cursor-grabbing'); + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', row.dataset.contentId || ''); + } + }, + + onDragOver(event) { + const tableBody = event.currentTarget; + + if (!this.dragSourceRow || tableBody !== this.dragSourceBody) { + return; + } + + event.preventDefault(); + + const nextRow = this.getRowAfterCursor(tableBody, event.clientY); + + if (nextRow === null) { + tableBody.appendChild(this.dragSourceRow); + + return; + } + + tableBody.insertBefore(this.dragSourceRow, nextRow); + }, + + onDrop(event) { + const tableBody = event.currentTarget; + + if (!this.dragSourceRow || tableBody !== this.dragSourceBody || !this.dragSourceContentType) { + return; + } + + event.preventDefault(); + this.dropCompleted = true; + this.persistOrder(tableBody, this.dragSourceContentType, [...this.originalOrder]); + }, + + onDragEnd() { + if (this.dragSourceRow && this.dragSourceBody && !this.dropCompleted) { + this.restoreOrder(this.dragSourceBody, this.originalOrder); + this.originalOrder = []; + } + + const handle = this.dragSourceRow?.querySelector('[data-drag-handle]'); + this.dragSourceRow?.classList.remove('opacity-60', 'is-dragging'); + handle?.classList.remove('cursor-grabbing'); + handle?.classList.add('cursor-grab'); + + this.dragSourceRow = null; + this.dragSourceBody = null; + this.dragSourceContentType = null; + this.dropCompleted = false; + }, + + getRowAfterCursor(tableBody, clientY) { + const rows = Array.from(tableBody?.querySelectorAll('tr[data-content-id]:not(.is-dragging)') || []); + + return rows.reduce((closestRow, row) => { + const box = row.getBoundingClientRect(); + const offset = clientY - box.top - (box.height / 2); + + if (offset < 0 && offset > closestRow.offset) { + return { offset, element: row }; + } + + return closestRow; + }, { offset: Number.NEGATIVE_INFINITY, element: null }).element; + }, + + getOrderedIds(tableBody) { + return Array.from(tableBody?.querySelectorAll('tr[data-content-id]') || []) + .map(row => row.dataset.contentId) + .filter(Boolean); + }, + + restoreOrder(tableBody, orderedIds) { + if (!tableBody || orderedIds.length === 0) { + return; + } + + const rowsById = new Map( + Array.from(tableBody.querySelectorAll('tr[data-content-id]')).map(row => [row.dataset.contentId, row]) + ); + + orderedIds.forEach(contentId => { + const row = rowsById.get(contentId); + + if (row) { + tableBody.appendChild(row); + } + }); + }, + + persistOrder(tableBody, contentType, previousOrder) { + const csrf = document.querySelector('meta[name="csrf-token"]')?.content; + const orderedIds = this.getOrderedIds(tableBody).map(Number); + const submittedOrder = orderedIds.map(String); + + if (!csrf || orderedIds.length === 0) { + this.restoreOrder(tableBody, previousOrder); + showToast('Error: Missing required data', 'error'); + return; + } + + this.isSavingOrder = true; + + fetch('/admin/content-reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' }, + body: JSON.stringify({ contenttype: Number(contentType), ordered_ids: orderedIds }) + }) + .then(async response => { + const data = await response.json().catch(() => ({})); + + if (!response.ok || !data.success) { + throw new Error(data.message || 'Failed to reorder content'); + } + + this.updateOrdinalCells(tableBody, data.ordinals || {}); + this.originalOrder = submittedOrder; + showToast(data.message || 'Content order updated successfully', 'success'); + }) + .catch(error => { + this.restoreOrder(tableBody, previousOrder); + showToast(error.message || 'An error occurred while updating content order', 'error'); + }) + .finally(() => { + this.isSavingOrder = false; + }); + }, + + updateOrdinalCells(tableBody, ordinals) { + if (!tableBody) { + return; + } + + Array.from(tableBody.querySelectorAll('tr[data-content-id]')).forEach(row => { + const ordinalCell = row.querySelector('[data-ordinal-cell]'); + const ordinal = ordinals[row.dataset.contentId]; + + if (ordinalCell && ordinal !== undefined) { + ordinalCell.textContent = ordinal; + row.dataset.ordinal = ordinal; + } + }); + }, + toggleStatus(contentId, currentStatus, el) { const csrf = document.querySelector('meta[name="csrf-token"]')?.content; if (!contentId || !csrf) { showToast('Error: Missing required data', 'error'); return; } @@ -26,7 +226,7 @@ Alpine.data('contentToggle', () => ({ .then(data => { if (data.success) { const row = el.closest('tr'); - const statusCell = row.cells[5]; + const statusCell = row.querySelector('[data-status-cell]'); const ns = data.status; if (ns === 1) { @@ -61,7 +261,7 @@ Alpine.data('contentToggle', () => ({ type: 'danger', confirmText: 'Delete', cancelText: 'Cancel', - onConfirm: function() { + onConfirm: () => { el.disabled = true; el.style.opacity = '0.6'; fetch('/admin/content-delete', { @@ -73,7 +273,17 @@ Alpine.data('contentToggle', () => ({ .then(data => { if (data.success) { const row = el.closest('tr'); - if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => { row.remove(); const tbody = document.querySelector('tbody'); if (tbody && tbody.children.length === 0) location.reload(); }, 300); } + if (row) { + row.style.transition = 'opacity 0.3s'; + row.style.opacity = '0'; + setTimeout(() => { + row.remove(); + const tbody = row.closest('tbody'); + if (tbody && tbody.children.length === 0) { + location.reload(); + } + }, 300); + } showToast(data.message, 'success'); } else { showToast(data.message || 'Failed to delete content', 'error'); el.disabled = false; el.style.opacity = '1'; } }) diff --git a/resources/views/admin/content/add.blade.php b/resources/views/admin/content/add.blade.php index d90517396..0c9741124 100644 --- a/resources/views/admin/content/add.blade.php +++ b/resources/views/admin/content/add.blade.php @@ -139,21 +139,6 @@ @enderror - -
- - -

Lower numbers appear first

- @error('ordinal') -

{{ $message }}

- @enderror -
diff --git a/resources/views/admin/content/index.blade.php b/resources/views/admin/content/index.blade.php index e27e59876..b229c7fc2 100644 --- a/resources/views/admin/content/index.blade.php +++ b/resources/views/admin/content/index.blade.php @@ -12,101 +12,137 @@ @if(count($contentlist) > 0) - - - ID - Title - URL - Type - Role - Status - Ordinal - Actions - +
+ Drag rows to update the order within each content group. Reordering Homepage items will not affect Useful Links. +
- @foreach($contentlist as $item) - - {{ $item->id }} - -
{{ filled($item->title) ? $item->title : 'Untitled' }}
- - - @if(!empty($item->url)) - - {{ Str::limit($item->url, 30) }} - - @else - N/A - @endif - - - @if($item->contenttype == 1) - - Useful Link - - @elseif($item->contenttype == 2) - - Article - - @elseif($item->contenttype == 3) - - Homepage - - @else - N/A - @endif - - - @if($item->role == 1) - Everyone - @elseif($item->role == 2) - Logged in Users - @elseif($item->role == 3) - Admins - @else - N/A - @endif - - - @if($item->status == 1) - - Enabled - - @else - - Disabled - - @endif - - {{ $item->ordinal ?? 0 }} - -
- - - - - +
+ @foreach($contentGroups as $group) +
+
+
+

{{ $group['label'] }}

+

Drag to reorder only items in this group.

- - + + {{ count($group['items']) }} items + +
+ + + + + Drag + + ID + Title + URL + Type + Role + Status + Ordinal + Actions + + + @foreach($group['items'] as $item) + + + + + {{ $item->id }} + +
{{ filled($item->title) ? $item->title : 'Untitled' }}
+ + + @if(!empty($item->url)) + + {{ Str::limit($item->url, 30) }} + + @else + N/A + @endif + + + @if($item->contenttype == 1) + + Useful Link + + @elseif($item->contenttype == 2) + + Article + + @elseif($item->contenttype == 3) + + Homepage + + @else + N/A + @endif + + + @if($item->role == 1) + Everyone + @elseif($item->role == 2) + Logged in Users + @elseif($item->role == 3) + Admins + @else + N/A + @endif + + + @if($item->status == 1) + + Enabled + + @else + + Disabled + + @endif + + {{ $item->ordinal ?? 0 }} + +
+ + + + + +
+ + + @endforeach +
+
@endforeach - +
@else prefix('admin')->group(function () { Route::post('role-delete', [AdminRoleController::class, 'destroy'])->name('admin.role-delete'); Route::get('content-list', [AdminContentController::class, 'index'])->name('admin.content-list'); Route::match(['GET', 'POST'], 'content-add', [AdminContentController::class, 'create'])->name('admin.content-add'); + Route::post('content-reorder', [AdminContentController::class, 'reorder'])->name('admin.content-reorder'); Route::post('content-toggle-status', [AdminContentController::class, 'toggleStatus'])->name('admin.content-toggle'); Route::post('content-delete', [AdminContentController::class, 'destroy'])->name('admin.content-delete'); diff --git a/tests/Feature/AdminContentControllerTest.php b/tests/Feature/AdminContentControllerTest.php index 0559631ec..d69164109 100644 --- a/tests/Feature/AdminContentControllerTest.php +++ b/tests/Feature/AdminContentControllerTest.php @@ -232,6 +232,173 @@ class AdminContentControllerTest extends TestCase $this->assertDatabaseMissing('content', ['id' => $content->id]); } + public function test_admin_content_list_orders_by_lowest_ordinal_first_within_each_group(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $this->createContent([ + 'title' => 'Lower Ordinal', + 'ordinal' => 2, + ]); + + $this->createContent([ + 'title' => 'Higher Ordinal', + 'ordinal' => 9, + ]); + + $this->createContent([ + 'title' => 'Homepage First', + 'contenttype' => Content::TYPE_INDEX, + 'ordinal' => 1, + ]); + + $this->createContent([ + 'title' => 'Homepage Second', + 'contenttype' => Content::TYPE_INDEX, + 'ordinal' => 3, + ]); + + $response = $this->actingAs($authenticatedAdmin)->get(route('admin.content-list')); + + $response->assertOk(); + $response->assertSeeInOrder(['Lower Ordinal', 'Higher Ordinal']); + $response->assertSeeInOrder(['Homepage First', 'Homepage Second']); + } + + public function test_admin_can_reorder_content_within_a_group_only(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $first = $this->createContent([ + 'title' => 'First', + 'ordinal' => 30, + ]); + + $second = $this->createContent([ + 'title' => 'Second', + 'ordinal' => 20, + ]); + + $third = $this->createContent([ + 'title' => 'Third', + 'ordinal' => 10, + ]); + + $homepage = $this->createContent([ + 'title' => 'Homepage Item', + 'contenttype' => Content::TYPE_INDEX, + 'ordinal' => 7, + ]); + + $response = $this->actingAs($authenticatedAdmin) + ->postJson(route('admin.content-reorder'), [ + 'contenttype' => Content::TYPE_USEFUL, + 'ordered_ids' => [$third->id, $first->id, $second->id], + ]); + + $response->assertOk(); + $response->assertJson([ + 'success' => true, + 'message' => 'Content order updated successfully', + ]); + + $this->assertSame( + [$third->id, $first->id, $second->id], + Content::query()->where('contenttype', Content::TYPE_USEFUL)->ordered()->pluck('id')->all() + ); + $this->assertSame(1, $third->fresh()->ordinal); + $this->assertSame(2, $first->fresh()->ordinal); + $this->assertSame(3, $second->fresh()->ordinal); + $this->assertSame(7, $homepage->fresh()->ordinal); + } + + public function test_new_content_defaults_to_bottom_ordinal_within_its_group(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $this->createContent([ + 'title' => 'Top Content', + 'ordinal' => 8, + ]); + + $this->createContent([ + 'title' => 'Bottom Content', + 'ordinal' => 4, + ]); + + $this->createContent([ + 'title' => 'Homepage Content', + 'contenttype' => Content::TYPE_INDEX, + 'ordinal' => 99, + ]); + + $response = $this->actingAs($authenticatedAdmin)->post(route('admin.content-add'), [ + 'action' => 'submit', + 'title' => 'Appended Content', + 'url' => 'appended', + 'body' => '

Appended body

', + 'metadescription' => 'Appended description', + 'metakeywords' => 'appended', + 'contenttype' => Content::TYPE_USEFUL, + 'status' => Content::STATUS_ENABLED, + 'role' => Content::ROLE_EVERYONE, + ]); + + $content = Content::query()->where('title', 'Appended Content')->firstOrFail(); + + $response->assertRedirect(route('admin.content-add', ['id' => $content->id])); + $this->assertSame(9, $content->ordinal); + } + + public function test_deleting_content_does_not_reassign_remaining_ordinals(): void + { + $admin = $this->createUserWithRole('Admin'); + /** @var Authenticatable $authenticatedAdmin */ + $authenticatedAdmin = $admin; + + $remainingContent = $this->createContent([ + 'title' => 'Keep Me', + 'ordinal' => 12, + ]); + + $deletedContent = $this->createContent([ + 'title' => 'Remove Me', + 'ordinal' => 5, + ]); + + $response = $this->actingAs($authenticatedAdmin) + ->postJson(route('admin.content-delete'), ['id' => $deletedContent->id]); + + $response->assertOk(); + $this->assertSame(12, $remainingContent->fresh()->ordinal); + $this->assertDatabaseMissing('content', ['id' => $deletedContent->id]); + } + + private function createContent(array $overrides = []): Content + { + $defaults = [ + 'title' => 'Test Content', + 'url' => '/test-content/', + 'body' => '

Test content body

', + 'metadescription' => 'Test description', + 'metakeywords' => 'test', + 'contenttype' => Content::TYPE_USEFUL, + 'status' => Content::STATUS_ENABLED, + 'ordinal' => 1, + 'role' => Content::ROLE_EVERYONE, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + return Content::query()->create(array_merge($defaults, $overrides)); + } + private function createSchema(): void { if (! Schema::hasTable('settings')) {