Add translation CRUD routes and controller

POST/DELETE for translations gated by can:update,document middleware.
Locale validated against SUPPORTED_LOCALES. Default-locale deletion
returns 422; duplicate-locale add returns 422. Flash messages added
to en/ja lang files (other locales updated in Task 9).
This commit is contained in:
Yutaka Kurosaki
2026-05-10 12:28:25 +09:00
parent 6d71f5fecf
commit 187349521d
5 changed files with 161 additions and 0 deletions
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use App\Http\Middleware\SetLocale;
use App\Models\Document;
use App\Services\DocumentService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class DocumentTranslationController extends Controller
{
public function __construct(private DocumentService $service) {}
public function store(Request $request, Document $document)
{
$validated = $request->validate([
'locale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
try {
$this->service->addTranslation(
$document,
$validated['locale'],
$validated['title'],
$validated['content'],
Auth::id(),
);
} catch (\InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return redirect()->route('documents.show', $document)
->with('message', __('messages.documents.translation_added'));
}
public function destroy(Document $document, string $locale)
{
if (!array_key_exists($locale, SetLocale::SUPPORTED_LOCALES)) {
abort(404);
}
try {
$this->service->deleteTranslation($document, $locale);
} catch (\InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return redirect()->route('documents.show', $document)
->with('message', __('messages.documents.translation_deleted'));
}
}
+2
View File
@@ -39,6 +39,8 @@
'content_label' => 'Content',
'content_placeholder' => 'Write your markdown here...',
'saving' => 'Saving...',
'translation_added' => 'Translation added.',
'translation_deleted' => 'Translation deleted.',
],
// Quick Switcher
+2
View File
@@ -39,6 +39,8 @@
'content_label' => '本文',
'content_placeholder' => 'Markdownで記述してください...',
'saving' => '保存中...',
'translation_added' => '翻訳を追加しました。',
'translation_deleted' => '翻訳を削除しました。',
],
// Quick Switcher
+9
View File
@@ -47,6 +47,15 @@
Route::get('/{document}/edit', DocumentEditor::class)
->middleware('can:update,document')
->name('edit');
Route::post('/{document}/translations', [\App\Http\Controllers\DocumentTranslationController::class, 'store'])
->middleware('can:update,document')
->name('translations.store');
Route::delete('/{document}/translations/{locale}', [\App\Http\Controllers\DocumentTranslationController::class, 'destroy'])
->middleware('can:update,document')
->name('translations.destroy');
Route::get('/{document}/translations/{locale}/edit', \App\Livewire\DocumentEditor::class)
->middleware('can:update,document')
->name('translations.edit');
});
// 公開ルート(動的ルートは最後に)
@@ -0,0 +1,94 @@
<?php
namespace Tests\Feature;
use App\Models\Document;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DocumentTranslationCrudTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_add_a_translation(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->post(
route('documents.translations.store', $doc),
['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文']
);
$response->assertRedirect();
$this->assertNotNull($doc->fresh()->translationFor('ja', false));
}
public function test_non_owner_cannot_add_translation(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($other)->post(
route('documents.translations.store', $doc),
['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文']
);
$response->assertForbidden();
}
public function test_invalid_locale_is_rejected(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->post(
route('documents.translations.store', $doc),
['locale' => 'xx', 'title' => 'X', 'content' => 'Y']
);
$response->assertSessionHasErrors('locale');
}
public function test_duplicate_locale_returns_422(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->post(
route('documents.translations.store', $doc),
['locale' => 'en', 'title' => 'X', 'content' => 'Y']
);
$response->assertStatus(422);
}
public function test_owner_can_delete_non_default_translation(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
app(\App\Services\DocumentService::class)->addTranslation($doc, 'ja', 'JA', 'body', $owner->id);
$response = $this->actingAs($owner)->delete(
route('documents.translations.destroy', ['document' => $doc, 'locale' => 'ja'])
);
$response->assertRedirect();
$this->assertNull($doc->fresh()->translationFor('ja', false));
}
public function test_default_locale_translation_cannot_be_deleted(): void
{
$owner = User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
$response = $this->actingAs($owner)->delete(
route('documents.translations.destroy', ['document' => $doc, 'locale' => 'en'])
);
$response->assertStatus(422);
$this->assertNotNull($doc->fresh()->translationFor('en', false));
}
}