From 99d1770c6cb4c3dcf8e3bd0319ff9d418bb522a9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 14:18:13 -0300 Subject: [PATCH] fix(post): edit redirect loop + frontend validations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostController@edit was redirecting Failed→show while show was redirecting Failed→edit, producing ERR_TOO_MANY_REDIRECTS. Failed posts now render in show. - New universal `hasContentOrMedia` rule in Edit.vue blocks publishing when both text and media are empty (closes the hole where empty posts could reach the publish button). - Unified `PLATFORM_VARIANTS` to include Facebook, Instagram and LinkedIn variants. togglePlatform snaps to a compatible variant when reselecting a platform whose current content_type is incompatible with the attached media (fixes the case where Reel+image left the tile permanently blocked). - platformIssues suppresses the issue on deselected tiles when a compatible variant exists, so the tile remains clickable and the snap can recover state. - Use ContentType enum in place of string literals. --- app/Http/Controllers/App/PostController.php | 2 +- lang/en/posts.php | 1 + lang/es/posts.php | 1 + lang/pt-BR/posts.php | 1 + resources/js/pages/posts/Edit.vue | 54 +++++++++++---------- tests/Feature/PostControllerTest.php | 14 +++++- 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index e02dbe95..a0a52f2a 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -197,7 +197,7 @@ public function show(Request $request, Post $post): Response|RedirectResponse $this->authorize('view', $post); - if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed], true)) { + if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled], true)) { return redirect()->route('app.posts.edit', $post); } diff --git a/lang/en/posts.php b/lang/en/posts.php index a2ccb8cd..5653d9c1 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -270,6 +270,7 @@ 'platform_status' => 'Platform status', 'compliance_incomplete' => 'Some platform settings are incomplete or incompatible with the attached media.', 'compliance' => [ + 'requires_content_or_media' => 'Add text or media to publish.', 'requires_media' => 'Add an image or video to publish here.', 'too_many_files' => 'Only :max file(s) allowed for this format.', 'too_few_files' => 'Add at least :min files for this format.', diff --git a/lang/es/posts.php b/lang/es/posts.php index ac4165f4..91a6d2c6 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -270,6 +270,7 @@ 'platform_status' => 'Estado de la plataforma', 'compliance_incomplete' => 'Algunas configuraciones de plataforma están incompletas o son incompatibles con los medios adjuntos.', 'compliance' => [ + 'requires_content_or_media' => 'Agrega texto o multimedia para publicar.', 'requires_media' => 'Agrega una imagen o video para publicar aquí.', 'too_many_files' => 'Solo se permiten :max archivo(s) en este formato.', 'too_few_files' => 'Agrega al menos :min archivos para este formato.', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 887c86ed..daaf75c7 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -270,6 +270,7 @@ 'platform_status' => 'Status da plataforma', 'compliance_incomplete' => 'Algumas configurações de plataforma estão incompletas ou incompatíveis com a mídia anexada.', 'compliance' => [ + 'requires_content_or_media' => 'Adicione texto ou mídia para publicar.', 'requires_media' => 'Adicione uma imagem ou vídeo para publicar aqui.', 'too_many_files' => 'Apenas :max arquivo(s) permitido(s) para este formato.', 'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.', diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index 17b92ee2..db3d4571 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -16,6 +16,7 @@ import { getMediaRulesForContentType } from '@/composables/useMediaRules'; import { getPlatformLabel } from '@/composables/usePlatformLogo'; import dayjs from '@/dayjs'; import debounce from '@/debounce'; +import { ContentType } from '@/enums/content-type'; import { Platform } from '@/enums/platform'; import AppLayout from '@/layouts/AppLayout.vue'; import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts'; @@ -211,18 +212,12 @@ const getMediaIncompatibilityReason = (contentType: string, mediaItems: MediaIte return null; }; -// Platforms whose default content_type doesn't accept every media type, -// so the tile would otherwise block silently when the user attaches the -// "wrong" kind. List the variants that should be considered at the tile -// level — togglePlatform snaps content_type to whichever fits the media. -// -// Instagram/Facebook/LinkedIn are intentionally NOT here: their defaults -// already accept image and video, and their secondary variants (Reel, -// Carousel) are picked manually by the user. Including them would hide -// real picker errors (e.g. Reel + image incompatibility) at the tile level. const PLATFORM_VARIANTS: Record = { - [Platform.TikTok]: ['tiktok_video', 'tiktok_photo'], - [Platform.Pinterest]: ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'], + [Platform.Facebook]: [ContentType.FacebookPost, ContentType.FacebookReel, ContentType.FacebookStory], + [Platform.Instagram]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory], + [Platform.LinkedIn]: [ContentType.LinkedInPost, ContentType.LinkedInCarousel, ContentType.LinkedInPagePost, ContentType.LinkedInPageCarousel], + [Platform.TikTok]: [ContentType.TikTokVideo, ContentType.TikTokPhoto], + [Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel], }; const firstCompatibleVariant = (platform: string, mediaItems: MediaItem[]): string | null => { @@ -241,18 +236,15 @@ const platformIssues = computed>(() => { continue; } - // For platforms that expose multiple content types via a variant picker, - // the tile is only blocked when no variant fits — togglePlatform will - // switch to a compatible variant on selection. - if (PLATFORM_VARIANTS[pp.platform]) { - if (!firstCompatibleVariant(pp.platform, media.value)) { - issues[pp.id] = getMediaIncompatibilityReason(contentType, media.value) ?? ''; - } + const reason = getMediaIncompatibilityReason(contentType, media.value); + if (!reason) continue; + + const isSelected = selectedPlatformIds.value.includes(pp.id); + if (!isSelected && firstCompatibleVariant(pp.platform, media.value)) { continue; } - const reason = getMediaIncompatibilityReason(contentType, media.value); - if (reason) issues[pp.id] = reason; + issues[pp.id] = reason; } return issues; @@ -285,6 +277,10 @@ const pinterestComplianceValid = computed(() => { return pinterestPlatforms.every((pp) => Boolean(platformMeta.value[pp.id]?.board_id)); }); +const hasContentOrMedia = computed( + () => content.value.trim().length > 0 || media.value.length > 0, +); + const contentLengthOverflows = computed(() => { const len = content.value.length; return platformLimits.value @@ -296,6 +292,7 @@ const canSchedule = computed( () => mediaCompliancePerPlatformValid.value && tiktokComplianceValid.value && pinterestComplianceValid.value + && hasContentOrMedia.value && contentLengthOverflows.value.length === 0, ); @@ -335,6 +332,10 @@ const postActionTooltip = computed(() => { return trans('posts.form.pinterest.board_required'); } + if (!hasContentOrMedia.value) { + return trans('posts.edit.compliance.requires_content_or_media'); + } + return trans('posts.edit.compliance_incomplete'); }); @@ -388,12 +389,15 @@ const togglePlatform = (platformId: string) => { if (isLocked.value) return; const index = selectedPlatformIds.value.indexOf(platformId); if (index === -1) { - // For platforms with a variant picker, snap the post-platform's - // content_type to one that fits the current media before selection. const pp = post.value.post_platforms.find((p) => p.id === platformId); - const variant = pp ? firstCompatibleVariant(pp.platform, media.value) : null; - if (variant && platformContentTypes.value[platformId] !== variant) { - platformContentTypes.value = { ...platformContentTypes.value, [platformId]: variant }; + const currentVariant = platformContentTypes.value[platformId]; + const currentIncompatible = pp && currentVariant + && getMediaIncompatibilityReason(currentVariant, media.value) !== null; + if (pp && currentIncompatible) { + const fallback = firstCompatibleVariant(pp.platform, media.value); + if (fallback) { + platformContentTypes.value = { ...platformContentTypes.value, [platformId]: fallback }; + } } selectedPlatformIds.value.push(platformId); } else { diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index ac722bd2..628b0f49 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -903,7 +903,7 @@ }); test('show page redirects editable posts to edit', function () { - foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) { + foreach ([PostStatus::Draft, PostStatus::Scheduled] as $status) { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, @@ -916,6 +916,18 @@ } }); +test('failed posts render show without redirecting to edit', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Failed, + ]); + + $this->actingAs($this->user) + ->get(route('app.posts.show', $post)) + ->assertOk(); +}); + test('destroy blocks published posts', function () { foreach ([PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished] as $status) { $post = Post::factory()->create([