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:
parent
0c955e885f
commit
99d1770c6c
6 changed files with 46 additions and 27 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue