Merge pull request #127 from dantaspaulo/feat/ai-prompt-char-counter

feat(ai-create): show character counter under the AI prompt field
This commit is contained in:
Paulo Castellano 2026-07-17 11:29:59 -03:00 committed by GitHub
commit 450b6fd3de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 120 additions and 3 deletions

View file

@ -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'],
];
}

View file

@ -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::wizardPromptRule(),
'date' => ['nullable', 'date_format:Y-m-d'],
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
];

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Single source of truth for the user-supplied AI generation prompt length,
* shared by the create wizard (StartPostCreationRequest) and the editor's
* content generation (GeneratePostContentRequest). The frontend counter in
* resources/js/components/posts/create/AiPostWizard.vue mirrors these bounds.
*/
class AiPromptRules
{
/**
* Minimum prompt length (characters) for the create wizard.
*/
public const PROMPT_MIN_LENGTH = 3;
/**
* Maximum prompt length (characters); mirrored by the frontend counter.
*/
public const PROMPT_MAX_LENGTH = 2000;
/**
* 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<int, string>
*/
public static function wizardPromptRule(): array
{
return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH];
}
}

View file

@ -63,6 +63,8 @@ const selectedAccountId = ref<string | null>(null);
const includeImages = ref(true);
const imageCount = ref(2);
const promptText = ref('');
const PROMPT_MIN = 3;
const PROMPT_MAX = 2000;
const submitting = ref(false);
@ -157,10 +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,
promptLength.value >= PROMPT_MIN &&
promptLength.value <= PROMPT_MAX,
);
// Auto-pick the only account when format has exactly one match.
@ -358,6 +363,14 @@ const startGeneration = async () => {
:placeholder="$t('posts.create.steps.prompt_placeholder')"
class="min-h-[140px] resize-none"
/>
<p
data-testid="ai-prompt-counter"
aria-live="polite"
class="text-right text-xs tabular-nums"
:class="promptLength > PROMPT_MAX ? 'font-semibold text-destructive' : 'text-muted-foreground'"
>
{{ promptLength }}/{{ PROMPT_MAX }}
</p>
</div>
<!-- Generate -->

View file

@ -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,57 @@
->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 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();
$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();

View file

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