106 - Add channel creation modal and Add button to Channels page
This commit is contained in:
parent
4b12f9df30
commit
d676d7866b
3 changed files with 381 additions and 1 deletions
|
|
@ -2,15 +2,30 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\CreateChannelAction;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
use RuntimeException;
|
||||
|
||||
class Channels extends Component
|
||||
{
|
||||
public ?int $managingChannelId = null;
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public string $newName = '';
|
||||
|
||||
public ?int $newPlatformInstanceId = null;
|
||||
|
||||
public ?int $newLanguageId = null;
|
||||
|
||||
public string $newDescription = '';
|
||||
|
||||
public function toggle(int $channelId): void
|
||||
{
|
||||
$channel = PlatformChannel::findOrFail($channelId);
|
||||
|
|
@ -18,6 +33,55 @@ public function toggle(int $channelId): void
|
|||
$channel->save();
|
||||
}
|
||||
|
||||
public function openCreateModal(): void
|
||||
{
|
||||
$this->reset(['newName', 'newPlatformInstanceId', 'newLanguageId', 'newDescription']);
|
||||
$this->resetErrorBag();
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function closeCreateModal(): void
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
}
|
||||
|
||||
public function createChannel(CreateChannelAction $action): void
|
||||
{
|
||||
$this->validate([
|
||||
// name doubles as the Lemmy community slug (used verbatim as channel_id for
|
||||
// community lookup at publish time), so it must be lowercase slug format.
|
||||
'newName' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
'regex:/^[a-z0-9_]+$/',
|
||||
Rule::unique('platform_channels', 'name')
|
||||
->where('platform_instance_id', $this->newPlatformInstanceId),
|
||||
],
|
||||
'newPlatformInstanceId' => 'required|integer|exists:platform_instances,id',
|
||||
'newLanguageId' => 'nullable|integer|exists:languages,id',
|
||||
], [
|
||||
'newName.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).',
|
||||
'newName.unique' => 'A channel with this name already exists for this instance.',
|
||||
]);
|
||||
|
||||
try {
|
||||
$action->execute(
|
||||
$this->newName,
|
||||
$this->newPlatformInstanceId,
|
||||
$this->newLanguageId,
|
||||
// Blade textarea binds an empty string when blank; the action expects null for "no description".
|
||||
$this->newDescription !== '' ? $this->newDescription : null,
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->addError('newPlatformInstanceId', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->closeCreateModal();
|
||||
}
|
||||
|
||||
public function openAccountModal(int $channelId): void
|
||||
{
|
||||
$this->managingChannelId = $channelId;
|
||||
|
|
@ -69,6 +133,8 @@ public function render(): View
|
|||
'channels' => $channels,
|
||||
'managingChannel' => $managingChannel,
|
||||
'availableAccounts' => $availableAccounts,
|
||||
'platformInstances' => PlatformInstance::where('is_active', true)->orderBy('name')->get(),
|
||||
'languages' => Language::where('is_active', true)->orderBy('name')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
<div class="p-6">
|
||||
<x-page-header title="Channels" subtitle="Manage your platform channels and linked accounts" />
|
||||
<x-page-header title="Channels" subtitle="Manage your platform channels and linked accounts">
|
||||
<button
|
||||
wire:click="openCreateModal"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Channel
|
||||
</button>
|
||||
</x-page-header>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@forelse ($channels as $channel)
|
||||
|
|
@ -98,6 +108,17 @@ class="text-red-500 hover:text-red-700"
|
|||
<p class="mt-1 text-sm text-gray-500">
|
||||
No platform channels have been configured yet.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
wire:click="openCreateModal"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
|
@ -155,4 +176,98 @@ class="w-full inline-flex justify-center rounded-md border border-gray-300 shado
|
|||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Create Channel Modal -->
|
||||
@if ($showCreateModal)
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closeCreateModal"></div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Add Channel</h3>
|
||||
<button wire:click="closeCreateModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form wire:submit="createChannel" class="space-y-4">
|
||||
<div>
|
||||
<label for="new-channel-name" class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-channel-name"
|
||||
wire:model="newName"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
/>
|
||||
@error('newName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-channel-instance" class="block text-sm font-medium text-gray-700">Platform Instance</label>
|
||||
<select
|
||||
id="new-channel-instance"
|
||||
wire:model="newPlatformInstanceId"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select an instance</option>
|
||||
@foreach ($platformInstances as $instance)
|
||||
<option value="{{ $instance->id }}">{{ $instance->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('newPlatformInstanceId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-channel-language" class="block text-sm font-medium text-gray-700">Language <span class="text-gray-400">(optional)</span></label>
|
||||
<select
|
||||
id="new-channel-language"
|
||||
wire:model="newLanguageId"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select a language</option>
|
||||
@foreach ($languages as $language)
|
||||
<option value="{{ $language->id }}">{{ $language->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('newLanguageId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-channel-description" class="block text-sm font-medium text-gray-700">Description <span class="text-gray-400">(optional)</span></label>
|
||||
<textarea
|
||||
id="new-channel-description"
|
||||
wire:model="newDescription"
|
||||
rows="2"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
></textarea>
|
||||
@error('newDescription') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="closeCreateModal"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="createChannel"
|
||||
class="inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
199
tests/Feature/Livewire/ChannelsTest.php
Normal file
199
tests/Feature/Livewire/ChannelsTest.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Livewire\Channels;
|
||||
use App\Models\Language;
|
||||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\PlatformInstance;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ChannelsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function instanceWithActiveAccount(): PlatformInstance
|
||||
{
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
||||
PlatformAccount::factory()->create([
|
||||
'instance_url' => 'https://lemmy.world',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function test_add_button_renders_when_channels_exist(): void
|
||||
{
|
||||
PlatformChannel::factory()->create(['name' => 'existing_channel']);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->assertSee('Add Channel')
|
||||
->assertSee('existing_channel');
|
||||
}
|
||||
|
||||
public function test_add_button_renders_in_empty_state(): void
|
||||
{
|
||||
Livewire::test(Channels::class)
|
||||
->assertSee('No channels')
|
||||
->assertSee('Add Channel');
|
||||
}
|
||||
|
||||
public function test_open_create_modal_shows_modal(): void
|
||||
{
|
||||
Livewire::test(Channels::class)
|
||||
->assertSet('showCreateModal', false)
|
||||
->call('openCreateModal')
|
||||
->assertSet('showCreateModal', true);
|
||||
}
|
||||
|
||||
public function test_create_channel_requires_name(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_channel_requires_platform_instance(): void
|
||||
{
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newPlatformInstanceId' => 'required']);
|
||||
}
|
||||
|
||||
public function test_create_channel_succeeds_and_attaches_account(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
$language = Language::factory()->create();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->set('newLanguageId', $language->id)
|
||||
->set('newDescription', 'A tech community')
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('showCreateModal', false);
|
||||
|
||||
$this->assertDatabaseHas('platform_channels', [
|
||||
'name' => 'tech_community',
|
||||
'display_name' => 'Tech_community',
|
||||
'platform_instance_id' => $instance->id,
|
||||
'language_id' => $language->id,
|
||||
'description' => 'A tech community',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$channel = PlatformChannel::where('name', 'tech_community')->firstOrFail();
|
||||
$this->assertCount(1, $channel->platformAccounts);
|
||||
}
|
||||
|
||||
public function test_create_channel_leaves_description_null_when_blank(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('platform_channels', [
|
||||
'name' => 'tech_community',
|
||||
'description' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_channel_rejects_non_slug_name(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'Tech News')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'regex']);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_create_channel_rejects_duplicate_name_on_same_instance(): void
|
||||
{
|
||||
$instance = $this->instanceWithActiveAccount();
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instance->id,
|
||||
'name' => 'tech_community',
|
||||
]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors(['newName' => 'unique'])
|
||||
->assertSet('showCreateModal', true);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 1);
|
||||
}
|
||||
|
||||
public function test_create_channel_allows_same_name_on_different_instance(): void
|
||||
{
|
||||
$instanceA = $this->instanceWithActiveAccount();
|
||||
$instanceB = PlatformInstance::factory()->create(['url' => 'https://lemmy.other']);
|
||||
PlatformAccount::factory()->create([
|
||||
'instance_url' => 'https://lemmy.other',
|
||||
'is_active' => true,
|
||||
]);
|
||||
PlatformChannel::factory()->create([
|
||||
'platform_instance_id' => $instanceB->id,
|
||||
'name' => 'tech_community',
|
||||
]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instanceA->id)
|
||||
->call('createChannel')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 2);
|
||||
}
|
||||
|
||||
public function test_create_channel_surfaces_no_active_accounts_error(): void
|
||||
{
|
||||
// Instance exists but has no active account for its url.
|
||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('openCreateModal')
|
||||
->set('newName', 'tech_community')
|
||||
->set('newPlatformInstanceId', $instance->id)
|
||||
->call('createChannel')
|
||||
->assertHasErrors('newPlatformInstanceId')
|
||||
->assertSet('showCreateModal', true);
|
||||
|
||||
$this->assertDatabaseCount('platform_channels', 0);
|
||||
}
|
||||
|
||||
public function test_toggle_flips_active_state(): void
|
||||
{
|
||||
$channel = PlatformChannel::factory()->create(['is_active' => true]);
|
||||
|
||||
Livewire::test(Channels::class)
|
||||
->call('toggle', $channel->id);
|
||||
|
||||
$this->assertFalse($channel->fresh()->is_active);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue