fix(post): edit redirect loop + frontend validations

- 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.
This commit is contained in:
Paulo Castellano 2026-05-19 14:18:13 -03:00
parent 0c955e885f
commit 99d1770c6c
6 changed files with 46 additions and 27 deletions

View file

@ -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);
}

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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<string, string[]> = {
[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<Record<string, string>>(() => {
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 {

View file

@ -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([