Improve support attachment validation errors
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\SupportConversation;
|
||||
use App\Rules\SupportAttachmentFile;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -23,7 +24,7 @@ class StoreSupportConversationRequest extends FormRequest
|
||||
'subject' => ['required', 'string', 'max:120'],
|
||||
'body' => ['required', 'string', 'max:2000'],
|
||||
'attachments' => ['nullable', 'array', 'max:3'],
|
||||
'attachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,pdf,txt,log,json,zip'],
|
||||
'attachments.*' => [new SupportAttachmentFile],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\SupportAttachmentFile;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreSupportMessageRequest extends FormRequest
|
||||
@@ -12,14 +13,14 @@ class StoreSupportMessageRequest extends FormRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['required', 'string', 'max:2000'],
|
||||
'attachments' => ['nullable', 'array', 'max:3'],
|
||||
'attachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,pdf,txt,log,json,zip'],
|
||||
'attachments.*' => [new SupportAttachmentFile],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
77
app/Rules/SupportAttachmentFile.php
Normal file
77
app/Rules/SupportAttachmentFile.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class SupportAttachmentFile implements ValidationRule
|
||||
{
|
||||
private const MAX_UPLOAD_KILOBYTES = 204800;
|
||||
|
||||
private const ALLOWED_MIME_TYPES = [
|
||||
'image/*',
|
||||
'video/*',
|
||||
'audio/*',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/csv',
|
||||
'application/json',
|
||||
'application/x-ndjson',
|
||||
'text/json',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/markdown',
|
||||
'application/x-yaml',
|
||||
'text/yaml',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/vnd.rar',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/rtf',
|
||||
'text/rtf',
|
||||
'application/vnd.oasis.opendocument.text',
|
||||
'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'application/vnd.oasis.opendocument.presentation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$validator = Validator::make(
|
||||
['attachment' => $value],
|
||||
[
|
||||
'attachment' => [
|
||||
'file',
|
||||
'max:'.self::MAX_UPLOAD_KILOBYTES,
|
||||
'mimetypes:'.implode(',', self::ALLOWED_MIME_TYPES),
|
||||
],
|
||||
],
|
||||
[
|
||||
'attachment.file' => 'Attachment upload failed. Try the file again or choose a different file.',
|
||||
'attachment.max' => 'Attachments must be 200 MB or smaller.',
|
||||
'attachment.mimetypes' => 'Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.',
|
||||
],
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$fail($validator->errors()->first('attachment'));
|
||||
}
|
||||
}
|
||||
}
|
||||
28
resources/js/components/support-attachment-errors.tsx
Normal file
28
resources/js/components/support-attachment-errors.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
type Props = {
|
||||
errors: Partial<Record<string, string>>;
|
||||
};
|
||||
|
||||
export function SupportAttachmentErrors({ errors }: Props) {
|
||||
const messages = Object.entries(errors)
|
||||
.filter(
|
||||
([key, message]) =>
|
||||
Boolean(message) &&
|
||||
(key === 'attachments' || key.startsWith('attachments.')),
|
||||
)
|
||||
.map(([key, message]) => ({
|
||||
key,
|
||||
message: message as string,
|
||||
}));
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{messages.map((item) => (
|
||||
<div key={item.key}>{item.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { SupportAttachmentErrors } from '@/components/support-attachment-errors';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -142,10 +143,7 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachments"
|
||||
error={form.errors.attachments}
|
||||
>
|
||||
<Field label="Attachments">
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -178,6 +176,9 @@ export default function AdminSupportShow({ conversation }: Props) {
|
||||
className="h-1.5 w-full"
|
||||
/>
|
||||
)}
|
||||
<SupportAttachmentErrors
|
||||
errors={form.errors}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { FileUp, LifeBuoy, MessageSquare, Send } from 'lucide-react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { SupportAttachmentErrors } from '@/components/support-attachment-errors';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -123,7 +124,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
<div className="mb-5">
|
||||
<h2 className="font-semibold">New conversation</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Attach up to 3 files, 10 MB each.
|
||||
Attach up to 3 files, 200 MB each.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
@@ -164,10 +165,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachments"
|
||||
error={form.errors.attachments}
|
||||
>
|
||||
<Field label="Attachments">
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -198,6 +196,7 @@ export default function SupportIndex({ categories, conversations }: Props) {
|
||||
className="h-1.5 w-full"
|
||||
/>
|
||||
)}
|
||||
<SupportAttachmentErrors errors={form.errors} />
|
||||
</Field>
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
<Send className="size-4" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { ArrowLeft, FileDown, FileUp, Lock, Send } from 'lucide-react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { SupportAttachmentErrors } from '@/components/support-attachment-errors';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -100,10 +101,7 @@ export default function SupportShow({ conversation }: Props) {
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachments"
|
||||
error={form.errors.attachments}
|
||||
>
|
||||
<Field label="Attachments">
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -134,6 +132,7 @@ export default function SupportShow({ conversation }: Props) {
|
||||
className="h-1.5 w-full"
|
||||
/>
|
||||
)}
|
||||
<SupportAttachmentErrors errors={form.errors} />
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -30,14 +30,15 @@ class SupportConversationTest extends TestCase
|
||||
Storage::fake('local');
|
||||
|
||||
$user = User::factory()->create(['can_create_channel' => false]);
|
||||
$attachment = UploadedFile::fake()->create('channel-request.pdf', 12, 'application/pdf');
|
||||
$attachment = UploadedFile::fake()->create('channel-request.pdf', 204800, 'application/pdf');
|
||||
$jsonAttachment = UploadedFile::fake()->create('payload.json', 1, 'application/json');
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('support.store'), [
|
||||
'category' => SupportConversation::CATEGORY_CHANNEL_REQUEST,
|
||||
'subject' => 'Please approve my creator channel',
|
||||
'body' => 'I want to start streaming tutorials.',
|
||||
'attachments' => [$attachment],
|
||||
'attachments' => [$attachment, $jsonAttachment],
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
@@ -51,6 +52,11 @@ class SupportConversationTest extends TestCase
|
||||
$this->assertSame('I want to start streaming tutorials.', $message->body);
|
||||
$this->assertFalse($user->refresh()->can_create_channel);
|
||||
$this->assertSame('channel-request.pdf', $storedAttachment->original_name);
|
||||
$this->assertDatabaseHas('support_attachments', [
|
||||
'support_message_id' => $message->id,
|
||||
'original_name' => 'payload.json',
|
||||
'mime_type' => 'application/json',
|
||||
]);
|
||||
Storage::disk('local')->assertExists($storedAttachment->path);
|
||||
}
|
||||
|
||||
@@ -66,7 +72,29 @@ class SupportConversationTest extends TestCase
|
||||
'body' => '',
|
||||
'attachments' => [$attachment],
|
||||
])
|
||||
->assertSessionHasErrors(['category', 'subject', 'body', 'attachments.0']);
|
||||
->assertSessionHasErrors([
|
||||
'category',
|
||||
'subject',
|
||||
'body',
|
||||
'attachments.0' => 'Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_media_attachment_must_not_exceed_two_hundred_megabytes(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$attachment = UploadedFile::fake()->image('too-large.jpg')->size(204801);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('support.store'), [
|
||||
'category' => SupportConversation::CATEGORY_BUG,
|
||||
'subject' => 'Large upload',
|
||||
'body' => 'This screenshot is too large.',
|
||||
'attachments' => [$attachment],
|
||||
])
|
||||
->assertSessionHasErrors([
|
||||
'attachments.0' => 'Attachments must be 200 MB or smaller.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_user_can_view_and_reply_to_own_conversation(): void
|
||||
|
||||
Reference in New Issue
Block a user