Add locale tabs to DocumentEditor

Editor accepts a locale URL parameter, loads the corresponding
translation (or empty form for new locales), and exposes
addTranslation/setDefaultLocale/deleteTranslation actions. Tab bar
shows existing locales with default-locale star and a + dropdown
for missing locales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yutaka Kurosaki
2026-05-10 12:40:54 +09:00
parent 97171960bd
commit 0100a0afb4
3 changed files with 182 additions and 27 deletions
+86 -27
View File
@@ -2,30 +2,44 @@
namespace App\Livewire;
use App\Http\Middleware\SetLocale;
use App\Models\Document;
use App\Services\DocumentService;
use Livewire\Component;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentEditor extends Component
{
public ?Document $document = null;
public $title = '';
public $content = '';
public $directory = '';
public $isEditMode = false;
public string $title = '';
public string $content = '';
public string $editingLocale = '';
public bool $isEditMode = false;
public bool $isNewLocale = false;
public array $availableLocales = [];
public function mount(?Document $document = null)
public function mount(?Document $document = null, ?string $locale = null)
{
if ($document) {
$this->authorize('update', $document);
$this->document = $document;
$this->title = $document->title;
$this->content = $document->content;
$this->directory = $document->directory;
$this->document = $document->load('translations');
$this->isEditMode = true;
$this->availableLocales = $document->availableLocales();
$this->editingLocale = $locale ?: ($document->default_locale ?? App::getLocale());
$translation = $document->translations->firstWhere('locale', $this->editingLocale);
if ($translation) {
$this->title = $translation->title;
$this->content = $translation->content;
$this->isNewLocale = false;
} else {
$this->title = '';
$this->content = '';
$this->isNewLocale = true;
}
} else {
$this->editingLocale = App::getLocale();
$titleParam = request()->query('title');
if ($titleParam) {
$this->title = $titleParam;
@@ -35,53 +49,96 @@ public function mount(?Document $document = null)
public function save(DocumentService $documentService)
{
$this->validate([
$validated = $this->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'editingLocale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
]);
try {
if ($this->isEditMode && $this->document) {
$this->authorize('update', $this->document);
$this->document = $documentService->updateDocument(
$this->document,
$this->title,
$this->content,
Auth::id()
);
if ($this->isNewLocale) {
$documentService->addTranslation(
$this->document,
$this->editingLocale,
$this->title,
$this->content,
Auth::id(),
);
$this->document->refresh()->load('translations');
} else {
$this->document = $documentService->updateDocument(
$this->document,
$this->title,
$this->content,
Auth::id(),
$this->editingLocale,
);
}
session()->flash('message', 'Document updated successfully!');
session()->flash('message', __('messages.documents.update_success'));
return $this->redirect(route('documents.show', $this->document));
} else {
$this->document = $documentService->createDocument(
$this->title,
$this->content,
Auth::id(),
$this->directory ?: null
$this->editingLocale,
);
session()->flash('message', 'Document created successfully!');
session()->flash('message', __('messages.documents.create_success'));
return $this->redirect(route('documents.show', $this->document));
}
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
} catch (\Exception $e) {
session()->flash('error', 'Error saving document: ' . $e->getMessage());
}
}
public function deleteTranslation(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document || $this->isNewLocale) {
return;
}
$this->authorize('update', $this->document);
try {
$documentService->deleteTranslation($this->document, $this->editingLocale);
session()->flash('message', __('messages.documents.translation_deleted'));
return $this->redirect(route('documents.show', $this->document));
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
}
}
public function setAsDefault(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document) {
return;
}
$this->authorize('update', $this->document);
try {
$this->document = $documentService->setDefaultLocale($this->document, $this->editingLocale);
session()->flash('message', __('messages.documents.update_success'));
return $this->redirect(route('documents.show', $this->document));
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
}
}
public function delete(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document) {
return;
}
$this->authorize('delete', $this->document);
try {
$documentService->deleteDocument($this->document);
session()->flash('message', 'Document deleted successfully!');
// Try to redirect to home document, or root if not found
session()->flash('message', __('messages.documents.delete_success'));
$homeDocument = Document::where('slug', 'home')->first();
if ($homeDocument) {
return redirect()->route('documents.show', $homeDocument);
@@ -96,7 +153,9 @@ public function render()
{
return view('livewire.document-editor')
->layout('layouts.knowledge-base', [
'title' => $this->isEditMode ? 'Edit: ' . $this->title : 'New Document'
'title' => $this->isEditMode
? __('messages.documents.edit_document') . ': ' . $this->title
: __('messages.documents.new_document'),
]);
}
}
@@ -56,6 +56,65 @@ class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 b
</div>
</div>
@if($isEditMode && $document)
<div class="mb-4 border-b border-gray-200" role="tablist" aria-label="{{ __('messages.documents.translation_tabs_label') }}">
<nav class="-mb-px flex flex-wrap gap-x-2">
@php $allLocales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES; @endphp
@foreach($availableLocales as $loc)
@php $isActive = ($loc === $editingLocale); @endphp
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
class="px-3 py-2 text-sm font-medium border-b-2 {{ $isActive ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
{{ __('messages.locale_names.' . $loc, [], 'en') }}
@if($loc === $document->default_locale)
<span class="ml-1 text-xs text-gray-400"></span>
@endif
</a>
@endforeach
@if($isNewLocale && $editingLocale)
<span class="px-3 py-2 text-sm font-medium border-b-2 border-indigo-500 text-indigo-600">
{{ __('messages.locale_names.' . $editingLocale, [], 'en') }}
<span class="ml-1 text-xs text-gray-400">({{ __('messages.documents.new_document') }})</span>
</span>
@endif
@php $missingLocales = array_diff(array_keys($allLocales), $availableLocales, $isNewLocale ? [$editingLocale] : []); @endphp
@if(!empty($missingLocales))
<div x-data="{ open: false }" class="relative">
<button type="button" @click="open = !open"
class="px-3 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
+ {{ __('messages.documents.add_translation') }}
</button>
<div x-show="open" @click.outside="open = false" x-cloak
class="absolute right-0 z-10 mt-2 w-48 max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
@foreach($missingLocales as $loc)
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
{{ $allLocales[$loc] }}
</a>
@endforeach
</div>
</div>
@endif
</nav>
@if($editingLocale !== $document->default_locale && !$isNewLocale)
<div class="mt-2 flex gap-2">
<button wire:click="setAsDefault" type="button"
class="px-2 py-1 text-xs text-indigo-600 border border-indigo-300 rounded hover:bg-indigo-50">
{{ __('messages.documents.set_as_default') }}
</button>
<button wire:click="deleteTranslation"
wire:confirm="{{ __('messages.documents.delete_confirm') }}"
type="button"
class="px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">
{{ __('messages.documents.delete_translation') ?? __('messages.documents.delete') }}
</button>
</div>
@endif
</div>
@endif
<!-- Form -->
<form wire:submit.prevent="save" class="space-y-6">
<!-- Title -->
+37
View File
@@ -65,4 +65,41 @@ public function test_no_banner_when_translation_exists(): void
$response->assertOk();
$response->assertDontSeeText(__('messages.documents.fallback_notice', [], 'ja'));
}
public function test_editor_loads_existing_translation_for_locale(): void
{
$owner = \App\Models\User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'EN body']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'こんにちは',
'content' => 'JA body',
]);
$response = $this->actingAs($owner)->get(route('documents.translations.edit', [
'document' => $doc,
'locale' => 'ja',
]));
$response->assertOk();
$response->assertSee('こんにちは');
$response->assertSee('JA body');
}
public function test_editor_for_missing_locale_shows_empty_form_with_new_locale_state(): void
{
$owner = \App\Models\User::factory()->create();
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor2']);
$response = $this->actingAs($owner)->get(route('documents.translations.edit', [
'document' => $doc,
'locale' => 'ja',
]));
$response->assertOk();
// The blade should render a tab marked active for ja with empty inputs
$response->assertSeeText(__('messages.locale_names.ja', [], 'en'));
}
}