From ffd7b45eb3eb32649cf81618eebd35211a32c2a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paulo=20S=C3=A9rgio=20Dantas?= Date: Wed, 1 Jul 2026 01:22:54 -0300 Subject: [PATCH 1/4] feat(ai-create): show character counter under the AI prompt field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI post prompt is validated with `max:2000` on the backend, but the create form gave no hint of the limit — users could write past it and only find out via a validation error on submit. Add a live `X/2000` counter below the "What is this post about?" textarea. It turns red (`text-destructive`) once the prompt exceeds the limit, and the generate button is disabled while over it (`canSubmit` now checks `length <= PROMPT_MAX`), so the limit is caught before the request. --- resources/js/components/posts/create/AiPostWizard.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index f02572d0..797f91f4 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -63,6 +63,8 @@ const selectedAccountId = ref(null); const includeImages = ref(true); const imageCount = ref(2); const promptText = ref(''); +// Mirrors the backend validation (`prompt` max:2000) so the limit is visible in the UI. +const PROMPT_MAX = 2000; const submitting = ref(false); @@ -160,7 +162,8 @@ const submittedImageCount = computed(() => { const canSubmit = computed(() => selectedFormat.value !== null && selectedAccountId.value !== null && - promptText.value.trim().length >= 3, + promptText.value.trim().length >= 3 && + promptText.value.length <= PROMPT_MAX, ); // Auto-pick the only account when format has exactly one match. @@ -358,6 +361,12 @@ const startGeneration = async () => { :placeholder="$t('posts.create.steps.prompt_placeholder')" class="min-h-[140px] resize-none" /> +

+ {{ promptText.length }}/{{ PROMPT_MAX }} +

From 3dbb8e6f7e3c9b1d638d7e3a258e6d903f1b588a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 17 Jul 2026 11:03:41 -0300 Subject: [PATCH 2/4] fix(ai-create): sync the prompt limit across front and back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter added earlier drifted from the backend in two ways: it counted UTF-16 code units over the raw (untrimmed) value, while the backend measures Unicode characters (mb_strlen) over the trimmed value that is actually sent — so emoji or trailing whitespace could falsely turn the counter red and block the button. The 2000 limit was also copied into three places, and the wizard's frontend `>= 3` minimum had no backend counterpart. - Add App\Support\AiPromptRules as the single source of truth for the prompt bounds; both StartPostCreationRequest and GeneratePostContentRequest use it. - Add min:3 to the create wizard endpoint so front and back agree (the editor's generate-content flow keeps `required` — it has no counter to mirror). - Count code points over the trimmed value in AiPostWizard so the counter and the submit gate match what the backend validates, matching AltTextDialog. - Cover min/max/boundary in PostAiCreateTest. --- .../App/Ai/GeneratePostContentRequest.php | 3 +- .../App/Ai/StartPostCreationRequest.php | 3 +- app/Support/AiPromptRules.php | 34 ++++++++++++++++ .../components/posts/create/AiPostWizard.vue | 12 +++--- tests/Feature/Ai/PostAiCreateTest.php | 39 +++++++++++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 app/Support/AiPromptRules.php diff --git a/app/Http/Requests/App/Ai/GeneratePostContentRequest.php b/app/Http/Requests/App/Ai/GeneratePostContentRequest.php index c8e1c854..a94dc534 100644 --- a/app/Http/Requests/App/Ai/GeneratePostContentRequest.php +++ b/app/Http/Requests/App/Ai/GeneratePostContentRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\App\Ai; +use App\Support\AiPromptRules; use Illuminate\Foundation\Http\FormRequest; class GeneratePostContentRequest extends FormRequest @@ -19,7 +20,7 @@ public function authorize(): bool public function rules(): array { return [ - 'prompt' => ['required', 'string', 'max:2000'], + 'prompt' => ['required', 'string', 'max:'.AiPromptRules::PROMPT_MAX_LENGTH], 'current_content' => ['nullable', 'string', 'max:10000'], ]; } diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 2d24a4fa..34ffb1bc 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -6,6 +6,7 @@ use App\Enums\Ai\ContentStyle; use App\Enums\PostPlatform\ContentType; +use App\Support\AiPromptRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; @@ -33,7 +34,7 @@ public function rules(): array ], 'social_account_id' => ['nullable', 'uuid'], 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], - 'prompt' => ['required', 'string', 'max:2000'], + 'prompt' => AiPromptRules::promptRule(), 'date' => ['nullable', 'date_format:Y-m-d'], 'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)], ]; diff --git a/app/Support/AiPromptRules.php b/app/Support/AiPromptRules.php new file mode 100644 index 00000000..9803cb90 --- /dev/null +++ b/app/Support/AiPromptRules.php @@ -0,0 +1,34 @@ + + */ + public static function promptRule(): array + { + return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH]; + } +} diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 797f91f4..4e304921 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -63,7 +63,7 @@ const selectedAccountId = ref(null); const includeImages = ref(true); const imageCount = ref(2); const promptText = ref(''); -// Mirrors the backend validation (`prompt` max:2000) so the limit is visible in the UI. +const PROMPT_MIN = 3; const PROMPT_MAX = 2000; const submitting = ref(false); @@ -159,11 +159,13 @@ const submittedImageCount = computed(() => { return 0; }); +const promptLength = computed(() => [...promptText.value.trim()].length); + const canSubmit = computed(() => selectedFormat.value !== null && selectedAccountId.value !== null && - promptText.value.trim().length >= 3 && - promptText.value.length <= PROMPT_MAX, + promptLength.value >= PROMPT_MIN && + promptLength.value <= PROMPT_MAX, ); // Auto-pick the only account when format has exactly one match. @@ -363,9 +365,9 @@ const startGeneration = async () => { />

- {{ promptText.length }}/{{ PROMPT_MAX }} + {{ promptLength }}/{{ PROMPT_MAX }}

diff --git a/tests/Feature/Ai/PostAiCreateTest.php b/tests/Feature/Ai/PostAiCreateTest.php index 8f588452..91e27642 100644 --- a/tests/Feature/Ai/PostAiCreateTest.php +++ b/tests/Feature/Ai/PostAiCreateTest.php @@ -8,6 +8,7 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Support\AiPromptRules; use Illuminate\Support\Facades\Bus; use Symfony\Component\HttpFoundation\Response; @@ -32,6 +33,44 @@ ->assertJsonValidationErrors(['prompt']); }); +test('start rejects a prompt shorter than the minimum length', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), ['prompt' => 'hi', 'format' => 'x_post']) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['prompt']); + + Bus::assertNotDispatched(StreamPostCreation::class); +}); + +test('start rejects a prompt longer than the maximum length', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH + 1), + 'format' => 'x_post', + ]) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['prompt']); + + Bus::assertNotDispatched(StreamPostCreation::class); +}); + +test('start accepts a prompt at the maximum length', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH), + 'format' => 'x_post', + ]) + ->assertStatus(Response::HTTP_ACCEPTED); + + Bus::assertDispatched(StreamPostCreation::class); +}); + test('start validates format is required', function () { Bus::fake(); From c432034dd151670a843b7b26d87d04da820f97d7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 17 Jul 2026 11:08:50 -0300 Subject: [PATCH 3/4] test(ai): guard prompt max on the editor endpoint and the wizard min boundary - Assert GeneratePostContentRequest rejects a prompt over the shared max, so the editor's limit is pinned explicitly (not only implied by the create wizard). - Assert the create wizard accepts a prompt at exactly the minimum length, complementing the below-minimum rejection. --- tests/Feature/Ai/PostAiCreateTest.php | 13 +++++++++++++ tests/Feature/Ai/PostAiGenerateTest.php | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tests/Feature/Ai/PostAiCreateTest.php b/tests/Feature/Ai/PostAiCreateTest.php index 91e27642..ec296325 100644 --- a/tests/Feature/Ai/PostAiCreateTest.php +++ b/tests/Feature/Ai/PostAiCreateTest.php @@ -44,6 +44,19 @@ Bus::assertNotDispatched(StreamPostCreation::class); }); +test('start accepts a prompt at the minimum length', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => str_repeat('a', AiPromptRules::PROMPT_MIN_LENGTH), + 'format' => 'x_post', + ]) + ->assertStatus(Response::HTTP_ACCEPTED); + + Bus::assertDispatched(StreamPostCreation::class); +}); + test('start rejects a prompt longer than the maximum length', function () { Bus::fake(); diff --git a/tests/Feature/Ai/PostAiGenerateTest.php b/tests/Feature/Ai/PostAiGenerateTest.php index ae775dfc..a80f44e6 100644 --- a/tests/Feature/Ai/PostAiGenerateTest.php +++ b/tests/Feature/Ai/PostAiGenerateTest.php @@ -7,6 +7,7 @@ use App\Models\Post; use App\Models\User; use App\Models\Workspace; +use App\Support\AiPromptRules; use Illuminate\Support\Facades\Bus; use Symfony\Component\HttpFoundation\Response; @@ -34,6 +35,19 @@ ->assertJsonValidationErrors(['prompt']); }); +test('endpoint rejects a prompt longer than the maximum length', function () { + Bus::fake(); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.generate', $this->post), [ + 'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH + 1), + ]) + ->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY) + ->assertJsonValidationErrors(['prompt']); + + Bus::assertNotDispatched(StreamPostContent::class); +}); + test('endpoint blocks access to other workspace posts', function () { Bus::fake(); $otherWorkspace = Workspace::factory()->create(); From e7f93f4aef80e580df5801f82d0458d46d24d2da Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 17 Jul 2026 11:26:15 -0300 Subject: [PATCH 4/4] refactor(ai-create): clarify prompt-rule naming and label the counter - Rename AiPromptRules::promptRule() to wizardPromptRule() so the asymmetry is explicit: only the create wizard carries a minimum; the editor's generation reuses just the shared maximum. - Add aria-live and a data-testid to the prompt counter so the over-limit state is announced to assistive tech and reachable from browser tests. --- app/Http/Requests/App/Ai/StartPostCreationRequest.php | 2 +- app/Support/AiPromptRules.php | 6 ++++-- resources/js/components/posts/create/AiPostWizard.vue | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 34ffb1bc..2b72365e 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -34,7 +34,7 @@ public function rules(): array ], 'social_account_id' => ['nullable', 'uuid'], 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], - 'prompt' => AiPromptRules::promptRule(), + 'prompt' => AiPromptRules::wizardPromptRule(), 'date' => ['nullable', 'date_format:Y-m-d'], 'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)], ]; diff --git a/app/Support/AiPromptRules.php b/app/Support/AiPromptRules.php index 9803cb90..c8b8ba6d 100644 --- a/app/Support/AiPromptRules.php +++ b/app/Support/AiPromptRules.php @@ -23,11 +23,13 @@ class AiPromptRules public const PROMPT_MAX_LENGTH = 2000; /** - * Validation rules for the create wizard's generation prompt. + * Validation rules for the create wizard's generation prompt. The editor's + * content generation reuses only PROMPT_MAX_LENGTH — it has no minimum, + * since it has no character counter to mirror one. * * @return array */ - public static function promptRule(): array + public static function wizardPromptRule(): array { return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH]; } diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 4e304921..49b277d5 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -364,6 +364,8 @@ const startGeneration = async () => { class="min-h-[140px] resize-none" />