feat: modal infrastructure + 11 modals (links, team, settings, workspace)

wire-elements/modal v3 integration:
- Replace custom Alpine modal-container with <livewire:livewire-ui-modal />
- Customize vendor modal view with design tokens (bg-s1, dark overlay, smooth transitions)
- All modals extend ModalComponent, use $this->closeModal()

Modals built (PHP + Blade view + tests):
- ConfirmAction — reusable danger confirm with custom event dispatch
- CreateLink, EditLink, DeleteLink — full CRUD with workspace isolation
- InviteMember, EditMemberRole, RemoveMember — team management
- CreateApiToken, RevokeApiToken — Sanctum token lifecycle
- CreateWebhook, DeleteWebhook — webhook CRUD
- CreateWorkspace (uses CreateWorkspaceAction), DeleteWorkspace (confirmation input)

Fixes:
- Remove SoftDeletes from Webhook model (table has no deleted_at column)
- Auto-generate webhook secret when user leaves field empty
- Use correct role enum: owner/admin/editor/viewer (not member)
- Protected Livewire model properties replaced with re-query pattern
- Sidebar "New Workspace" button now dispatches openModal

Tests: 105/105 passing, 211 assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 12:41:14 +02:00
parent 00673f3119
commit fa9985cc33
32 changed files with 1462 additions and 38 deletions

View File

@ -8,12 +8,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Webhook extends Model
{
/** @use HasFactory<WebhookFactory> */
use HasFactory, SoftDeletes;
use HasFactory;
protected $guarded = ['id'];

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Modals;
use LivewireUI\Modal\ModalComponent;
class ConfirmAction extends ModalComponent
{
public string $title = 'Confirm';
public string $message = 'Are you sure?';
public string $confirmLabel = 'Confirm';
public string $cancelLabel = 'Cancel';
public string $confirmEvent = '';
public array $confirmEventParams = [];
public bool $danger = false;
public function confirm(): void
{
if ($this->confirmEvent) {
$this->dispatch($this->confirmEvent, ...$this->confirmEventParams);
}
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.confirm-action');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Livewire\Modals;
use LivewireUI\Modal\ModalComponent;
class CreateApiToken extends ModalComponent
{
public string $name = '';
public ?string $plainTextToken = null;
protected function rules(): array
{
return [
'name' => 'required|string|max:100',
];
}
public function create(): void
{
$this->validate();
$token = auth()->user()->createToken($this->name);
$this->plainTextToken = $token->plainTextToken;
$this->dispatch('apiTokenCreated');
}
public static function closeModalOnClickAway(): bool
{
return false;
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.create-api-token');
}
}

View File

@ -3,10 +3,10 @@
namespace App\Livewire\Modals;
use App\Domains\Link\Models\Link;
use Livewire\Component;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
class CreateLink extends Component
class CreateLink extends ModalComponent
{
public string $targetUrl = '';
public string $slug = '';
@ -43,7 +43,7 @@ class CreateLink extends Component
]);
$this->dispatch('linkCreated');
$this->dispatch('close-modal');
$this->closeModal();
}
public function render(): \Illuminate\View\View

View File

@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Webhook\Models\Webhook;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
class CreateWebhook extends ModalComponent
{
public string $url = '';
public string $secret = '';
public array $selectedEvents = [];
public array $availableEvents = [
'link.clicked' => 'Link clicked',
'link.created' => 'Link created',
'link.updated' => 'Link updated',
'link.deleted' => 'Link deleted',
];
protected function rules(): array
{
return [
'url' => 'required|url|max:2048',
'secret' => 'nullable|string|max:255',
'selectedEvents' => 'required|array|min:1',
'selectedEvents.*' => 'string|in:' . implode(',', array_keys($this->availableEvents)),
];
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
Webhook::create([
'workspace_id' => $workspace->id,
'url' => $this->url,
'secret' => $this->secret ?: Str::random(32),
'events' => $this->selectedEvents,
'is_active' => true,
]);
$this->dispatch('webhookCreated');
$this->closeModal();
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.create-webhook');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Workspace\Actions\CreateWorkspace as CreateWorkspaceAction;
use LivewireUI\Modal\ModalComponent;
class CreateWorkspace extends ModalComponent
{
public string $name = '';
protected function rules(): array
{
return [
'name' => 'required|string|max:80',
];
}
public function create(): void
{
$this->validate();
$workspace = (new CreateWorkspaceAction)->handle(auth()->user(), ['name' => $this->name]);
$this->dispatch('workspaceCreated', workspaceUlid: $workspace->ulid);
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.create-workspace');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Link\Models\Link;
use LivewireUI\Modal\ModalComponent;
class DeleteLink extends ModalComponent
{
public string $linkUlid = '';
public string $linkTitle = '';
public function mount(string $linkUlid): void
{
$workspace = app('current_workspace');
$link = Link::where('ulid', $linkUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->linkUlid = $linkUlid;
$this->linkTitle = $link->title ?? $link->slug;
}
public function delete(): void
{
$workspace = app('current_workspace');
Link::where('ulid', $this->linkUlid)
->where('workspace_id', $workspace->id)
->firstOrFail()
->delete();
$this->dispatch('linkDeleted', linkUlid: $this->linkUlid);
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.delete-link');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Webhook\Models\Webhook;
use LivewireUI\Modal\ModalComponent;
class DeleteWebhook extends ModalComponent
{
public int $webhookId = 0;
public string $webhookUrl = '';
public function mount(int $webhookId): void
{
$workspace = app('current_workspace');
$webhook = Webhook::where('id', $webhookId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->webhookId = $webhookId;
$this->webhookUrl = $webhook->url;
}
public function delete(): void
{
$workspace = app('current_workspace');
Webhook::where('id', $this->webhookId)
->where('workspace_id', $workspace->id)
->firstOrFail()
->delete();
$this->dispatch('webhookDeleted', webhookId: $this->webhookId);
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.delete-webhook');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Workspace\Models\Workspace;
use LivewireUI\Modal\ModalComponent;
class DeleteWorkspace extends ModalComponent
{
public string $workspaceUlid = '';
public string $workspaceName = '';
public string $confirmation = '';
public function mount(string $workspaceUlid): void
{
$workspace = Workspace::where('ulid', $workspaceUlid)
->where('owner_id', auth()->id())
->firstOrFail();
$this->workspaceUlid = $workspaceUlid;
$this->workspaceName = $workspace->name;
}
protected function rules(): array
{
return [
'confirmation' => 'required|in:' . $this->workspaceName,
];
}
protected function messages(): array
{
return [
'confirmation.in' => 'Please type the workspace name exactly to confirm.',
];
}
public function delete(): void
{
$this->validate();
Workspace::where('ulid', $this->workspaceUlid)
->where('owner_id', auth()->id())
->firstOrFail()
->delete();
$this->dispatch('workspaceDeleted', workspaceUlid: $this->workspaceUlid);
$this->closeModal();
}
public static function closeModalOnClickAway(): bool
{
return false;
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.delete-workspace');
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Link\Models\Link;
use LivewireUI\Modal\ModalComponent;
class EditLink extends ModalComponent
{
public string $linkUlid = '';
public int $linkId = 0;
public string $targetUrl = '';
public string $slug = '';
public string $title = '';
public string $status = 'active';
public function mount(string $linkUlid): void
{
$workspace = app('current_workspace');
$link = Link::where('ulid', $linkUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->linkUlid = $linkUlid;
$this->linkId = $link->id;
$this->targetUrl = $link->target_url;
$this->slug = $link->slug;
$this->title = $link->title ?? '';
$this->status = $link->status;
}
protected function rules(): array
{
return [
'targetUrl' => 'required|url|max:2048',
'slug' => 'required|alpha_dash|max:50|unique:links,slug,' . $this->linkId,
'title' => 'nullable|max:200',
'status' => 'required|in:active,inactive',
];
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
$link = Link::where('ulid', $this->linkUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$link->update([
'target_url' => $this->targetUrl,
'slug' => $this->slug,
'title' => $this->title ?: null,
'status' => $this->status,
]);
$this->dispatch('linkUpdated', linkUlid: $this->linkUlid);
$this->closeModal();
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.edit-link');
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Workspace\Models\WorkspaceMember;
use LivewireUI\Modal\ModalComponent;
class EditMemberRole extends ModalComponent
{
public int $memberId = 0;
public string $memberName = '';
public string $role = 'editor';
protected function rules(): array
{
return [
'role' => 'required|in:admin,editor,viewer',
];
}
public function mount(int $memberId): void
{
$workspace = app('current_workspace');
$member = WorkspaceMember::where('id', $memberId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->memberId = $memberId;
$this->memberName = $member->user->name ?? '';
$this->role = $member->role;
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
$member = WorkspaceMember::where('id', $this->memberId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$member->update(['role' => $this->role]);
$this->dispatch('memberRoleUpdated', memberId: $this->memberId);
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.edit-member-role');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Workspace\Models\WorkspaceInvitation;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
class InviteMember extends ModalComponent
{
public string $email = '';
public string $role = 'editor';
protected function rules(): array
{
return [
'email' => 'required|email|max:255',
'role' => 'required|in:admin,editor,viewer',
];
}
public function invite(): void
{
$this->validate();
$workspace = app('current_workspace');
WorkspaceInvitation::create([
'workspace_id' => $workspace->id,
'email' => strtolower($this->email),
'role' => $this->role,
'token' => Str::random(40),
'invited_by' => auth()->id(),
'expires_at' => now()->addDays(7),
]);
$this->dispatch('memberInvited');
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.invite-member');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Workspace\Models\WorkspaceMember;
use LivewireUI\Modal\ModalComponent;
class RemoveMember extends ModalComponent
{
public int $memberId = 0;
public string $memberName = '';
public function mount(int $memberId): void
{
$workspace = app('current_workspace');
$member = WorkspaceMember::where('id', $memberId)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->memberId = $memberId;
$this->memberName = $member->user->name ?? '';
}
public function remove(): void
{
$workspace = app('current_workspace');
WorkspaceMember::where('id', $this->memberId)
->where('workspace_id', $workspace->id)
->firstOrFail()
->delete();
$this->dispatch('memberRemoved', memberId: $this->memberId);
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.remove-member');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Modals;
use Laravel\Sanctum\PersonalAccessToken;
use LivewireUI\Modal\ModalComponent;
class RevokeApiToken extends ModalComponent
{
public int $tokenId = 0;
public string $tokenName = '';
public function mount(int $tokenId): void
{
$token = PersonalAccessToken::where('id', $tokenId)
->where('tokenable_id', auth()->id())
->where('tokenable_type', get_class(auth()->user()))
->firstOrFail();
$this->tokenId = $tokenId;
$this->tokenName = $token->name;
}
public function revoke(): void
{
PersonalAccessToken::where('id', $this->tokenId)
->where('tokenable_id', auth()->id())
->where('tokenable_type', get_class(auth()->user()))
->firstOrFail()
->delete();
$this->dispatch('apiTokenRevoked', tokenId: $this->tokenId);
$this->closeModal();
}
public static function modalMaxWidth(): string
{
return 'sm';
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.revoke-api-token');
}
}

View File

@ -1,29 +1 @@
<div
x-data="{ open: false, component: null }"
@open-modal.window="open = true; component = $event.detail.component"
@close-modal.window="open = false"
>
<div
x-show="open"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
style="display: none;"
>
<div class="absolute inset-0 bg-black/70" @click="open = false"></div>
<div class="relative bg-s1 border border-white/[.06] rounded-2xl shadow-2xl w-full max-w-md z-10"
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95">
@livewire('modals.create-link')
</div>
</div>
</div>
<livewire:livewire-ui-modal />

View File

@ -143,12 +143,14 @@
<div class="px-4 py-2 text-xs text-t3">No workspaces yet</div>
@endforelse
<div class="border-t border-white/[.06] mt-1 pt-1">
<a href="#" class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors">
<button
wire:click="$dispatch('openModal', {component: 'modals.create-workspace'})"
class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors w-full text-left">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 3v10M3 8h10"/>
</svg>
New Workspace
</a>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div class="p-6">
<div class="flex items-start gap-4">
@if($danger)
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9V5m0 8h.01M4.93 4.93l10.14 10.14M10 18a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</div>
@endif
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">{{ $title }}</h3>
<p class="mt-1.5 text-sm text-t2">{{ $message }}</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button wire:click="closeModal"
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
{{ $cancelLabel }}
</button>
<button wire:click="confirm"
class="px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90 {{ $danger ? 'bg-red text-white' : 'bg-blue text-white' }}">
{{ $confirmLabel }}
</button>
</div>
</div>

View File

@ -0,0 +1,43 @@
<div class="p-6">
@if($plainTextToken)
<h3 class="text-lg font-semibold text-t1 mb-2">Token Created</h3>
<p class="text-sm text-t2 mb-4">Copy this token now it won't be shown again.</p>
<div class="flex items-center gap-2 bg-s2 border border-white/[.06] rounded-lg px-3 py-2.5">
<code class="flex-1 text-xs text-green font-mono break-all">{{ $plainTextToken }}</code>
<button
x-data
x-on:click="navigator.clipboard.writeText('{{ $plainTextToken }}'); $el.textContent = 'Copied!'; setTimeout(() => $el.textContent = 'Copy', 2000)"
class="text-xs text-t2 hover:text-t1 transition-colors flex-shrink-0 ml-2">
Copy
</button>
</div>
<div class="flex justify-end mt-6">
<button wire:click="closeModal"
class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Done
</button>
</div>
@else
<h3 class="text-lg font-semibold text-t1 mb-5">Create API Token</h3>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Token name *</label>
<input wire:model="name" type="text" placeholder="My integration"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('name') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div class="flex gap-3 mt-6">
<button wire:click="create"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Token
</button>
<button wire:click="closeModal"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
@endif
</div>

View File

@ -33,7 +33,7 @@
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Link
</button>
<button x-on:click="$dispatch('close-modal')"
<button wire:click="closeModal"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>

View File

@ -0,0 +1,44 @@
<div class="p-6">
<h3 class="text-lg font-semibold text-t1 mb-5">Create Webhook</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Endpoint URL *</label>
<input wire:model="url" type="url" placeholder="https://example.com/webhook"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('url') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Secret (optional)</label>
<input wire:model="secret" type="text" placeholder="Used to sign payloads"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50 font-mono">
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-2">Events *</label>
<div class="space-y-2">
@foreach($availableEvents as $event => $label)
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" wire:model="selectedEvents" value="{{ $event }}"
class="w-4 h-4 rounded border-white/[.2] bg-s2 text-blue focus:ring-blue/50 focus:ring-offset-0">
<span class="text-sm text-t1">{{ $label }}</span>
<span class="text-xs text-t3 font-mono">{{ $event }}</span>
</label>
@endforeach
</div>
@error('selectedEvents') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex gap-3 mt-6">
<button wire:click="save"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Webhook
</button>
<button wire:click="closeModal"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,21 @@
<div class="p-6">
<h3 class="text-lg font-semibold text-t1 mb-5">New Workspace</h3>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Workspace name *</label>
<input wire:model="name" type="text" placeholder="Acme Inc."
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('name') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div class="flex gap-3 mt-6">
<button wire:click="create"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Workspace
</button>
<button wire:click="closeModal"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Delete Link</h3>
<p class="mt-1.5 text-sm text-t2">
Delete <span class="text-t1 font-medium">{{ $linkTitle }}</span>? This cannot be undone. All click analytics will be permanently removed.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button wire:click="closeModal"
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
<button wire:click="delete"
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Delete Link
</button>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Delete Webhook</h3>
<p class="mt-1.5 text-sm text-t2">
Delete endpoint <span class="text-t1 font-mono text-xs break-all">{{ $webhookUrl }}</span>? Delivery history will be removed.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button wire:click="closeModal"
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
<button wire:click="delete"
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Delete
</button>
</div>
</div>

View File

@ -0,0 +1,35 @@
<div class="p-6">
<div class="flex items-start gap-4 mb-5">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9V5m0 8h.01M4.93 4.93l10.14 10.14M10 18a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Delete Workspace</h3>
<p class="mt-1.5 text-sm text-t2">
This permanently deletes <span class="text-t1 font-medium">{{ $workspaceName }}</span> and all its links, analytics, domains, and members. This action cannot be undone.
</p>
</div>
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">
Type <span class="text-t1 font-mono">{{ $workspaceName }}</span> to confirm
</label>
<input wire:model="confirmation" type="text"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-red/50 focus:border-red/50">
@error('confirmation') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div class="flex gap-3 mt-6 justify-end">
<button wire:click="closeModal"
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
<button wire:click="delete"
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Delete Workspace
</button>
</div>
</div>

View File

@ -0,0 +1,48 @@
<div class="p-6">
<h3 class="text-lg font-semibold text-t1 mb-5">Edit Link</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Target URL *</label>
<input wire:model="targetUrl" type="url" placeholder="https://example.com/long-url"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('targetUrl') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Slug *</label>
<div class="flex items-center bg-s2 border border-white/[.06] rounded-lg overflow-hidden focus-within:ring-1 focus-within:ring-blue/50">
<span class="px-3 py-2.5 text-sm text-t3 border-r border-white/[.06] flex-shrink-0">nimu.li/</span>
<input wire:model="slug" type="text"
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-t1 placeholder-t3 focus:outline-none font-mono">
</div>
@error('slug') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Title (optional)</label>
<input wire:model="title" type="text" placeholder="My link"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Status</label>
<select wire:model="status"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<div class="flex gap-3 mt-6">
<button wire:click="save"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Save Changes
</button>
<button wire:click="closeModal"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,25 @@
<div class="p-6">
<h3 class="text-lg font-semibold text-t1 mb-1">Change Role</h3>
<p class="text-sm text-t2 mb-5">Update role for <span class="text-t1 font-medium">{{ $memberName }}</span></p>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Role</label>
<select wire:model="role"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button wire:click="closeModal"
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
<button wire:click="save"
class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Save
</button>
</div>
</div>

View File

@ -0,0 +1,35 @@
<div class="p-6">
<h3 class="text-lg font-semibold text-t1 mb-5">Invite Team Member</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Email address *</label>
<input wire:model="email" type="email" placeholder="colleague@example.com"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('email') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Role</label>
<select wire:model="role"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<p class="mt-3 text-xs text-t3">An invitation link will be sent to this address and expires in 7 days.</p>
<div class="flex gap-3 mt-6">
<button wire:click="invite"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Send Invite
</button>
<button wire:click="closeModal"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7a3 3 0 11-6 0 3 3 0 016 0zm-8 10a5 5 0 0110 0H5z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Remove Member</h3>
<p class="mt-1.5 text-sm text-t2">
Remove <span class="text-t1 font-medium">{{ $memberName }}</span> from this workspace? They will lose access immediately.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button wire:click="closeModal"
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
<button wire:click="remove"
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Remove
</button>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div class="p-6">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 9h6m-3-3v6"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-t1">Revoke Token</h3>
<p class="mt-1.5 text-sm text-t2">
Revoke <span class="text-t1 font-medium">{{ $tokenName }}</span>? Any integrations using this token will stop working immediately.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button wire:click="closeModal"
class="px-4 py-2 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
<button wire:click="revoke"
class="px-4 py-2 bg-red text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Revoke
</button>
</div>
</div>

View File

@ -0,0 +1,53 @@
<div>
@isset($jsPath)
<script>{!! file_get_contents($jsPath) !!}</script>
@endisset
@isset($cssPath)
<style>{!! file_get_contents($cssPath) !!}</style>
@endisset
<div
x-data="LivewireUIModal()"
x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()"
x-show="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: none;"
>
<!-- Backdrop -->
<div
x-show="show"
x-on:click="closeModalOnClickAway()"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 bg-black/70"
></div>
<!-- Panel -->
<div
x-show="show && showActiveComponent"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
x-bind:class="modalWidth"
class="relative w-full bg-s1 border border-white/[.06] rounded-2xl shadow-2xl z-10 overflow-hidden"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
>
@forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
@livewire($component['name'], $component['arguments'], key($id))
</div>
@empty
@endforelse
</div>
</div>
</div>

View File

@ -0,0 +1,168 @@
<?php
use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceMember;
use App\Livewire\Modals\CreateLink;
use App\Livewire\Modals\DeleteLink;
use App\Livewire\Modals\EditLink;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Livewire\Livewire;
function makeUserWithWs(): array
{
$user = User::factory()->create();
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
WorkspaceMember::create([
'workspace_id' => $ws->id,
'user_id' => $user->id,
'role' => 'owner',
'joined_at' => now(),
]);
return [$user, $ws];
}
// CreateLink
it('renders create link modal', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateLink::class)
->assertOk()
->assertSee('Create Short Link');
});
it('validates required url on create link', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateLink::class)
->call('save')
->assertHasErrors(['targetUrl' => 'required']);
});
it('validates url format on create link', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateLink::class)
->set('targetUrl', 'not-a-url')
->call('save')
->assertHasErrors(['targetUrl']);
});
it('creates link and dispatches linkCreated', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateLink::class)
->set('targetUrl', 'https://example.com/page')
->call('save')
->assertDispatched('linkCreated');
expect(Link::where('workspace_id', $ws->id)->where('target_url', 'https://example.com/page')->exists())->toBeTrue();
});
it('generate slug fills slug field when empty', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
$component = Livewire::actingAs($user)->test(CreateLink::class)
->call('generateSlug');
expect($component->get('slug'))->not->toBeEmpty();
});
// EditLink
it('renders edit link modal with prefilled data', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
$link = Link::factory()->create([
'workspace_id' => $ws->id,
'title' => 'My Link',
'slug' => 'mylink',
'target_url' => 'https://old.example.com',
]);
Livewire::actingAs($user)->test(EditLink::class, ['linkUlid' => $link->ulid])
->assertOk()
->assertSet('targetUrl', 'https://old.example.com')
->assertSet('slug', 'mylink');
});
it('updates link on save', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
$link = Link::factory()->create([
'workspace_id' => $ws->id,
'slug' => 'oldslug',
'target_url' => 'https://old.example.com',
]);
Livewire::actingAs($user)->test(EditLink::class, ['linkUlid' => $link->ulid])
->set('targetUrl', 'https://new.example.com')
->set('slug', 'newslug')
->call('save')
->assertDispatched('linkUpdated');
expect($link->fresh()->target_url)->toBe('https://new.example.com');
expect($link->fresh()->slug)->toBe('newslug');
});
it('rejects edit for link in different workspace', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
$otherOwner = User::factory()->create();
$otherWs = Workspace::factory()->create(['owner_id' => $otherOwner->id]);
$link = Link::factory()->create(['workspace_id' => $otherWs->id]);
expect(fn () => Livewire::actingAs($user)->test(EditLink::class, ['linkUlid' => $link->ulid]))
->toThrow(ModelNotFoundException::class);
});
// DeleteLink
it('renders delete link modal', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
$link = Link::factory()->create([
'workspace_id' => $ws->id,
'title' => 'My Link',
]);
Livewire::actingAs($user)->test(DeleteLink::class, ['linkUlid' => $link->ulid])
->assertOk()
->assertSee('Delete Link');
});
it('soft-deletes link and dispatches linkDeleted', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
$link = Link::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteLink::class, ['linkUlid' => $link->ulid])
->call('delete')
->assertDispatched('linkDeleted');
expect(Link::withTrashed()->find($link->id)->deleted_at)->not->toBeNull();
});
it('rejects delete for link in different workspace', function () {
[$user, $ws] = makeUserWithWs();
app()->instance('current_workspace', $ws);
$otherOwner = User::factory()->create();
$otherWs = Workspace::factory()->create(['owner_id' => $otherOwner->id]);
$link = Link::factory()->create(['workspace_id' => $otherWs->id]);
expect(fn () => Livewire::actingAs($user)->test(DeleteLink::class, ['linkUlid' => $link->ulid]))
->toThrow(ModelNotFoundException::class);
});

View File

@ -0,0 +1,271 @@
<?php
use App\Domains\Webhook\Models\Webhook;
use App\Domains\Workspace\Models\Workspace;
use App\Domains\Workspace\Models\WorkspaceInvitation;
use App\Domains\Workspace\Models\WorkspaceMember;
use App\Livewire\Modals\ConfirmAction;
use App\Livewire\Modals\CreateApiToken;
use App\Livewire\Modals\CreateWebhook;
use App\Livewire\Modals\CreateWorkspace;
use App\Livewire\Modals\DeleteWebhook;
use App\Livewire\Modals\DeleteWorkspace;
use App\Livewire\Modals\EditMemberRole;
use App\Livewire\Modals\InviteMember;
use App\Livewire\Modals\RemoveMember;
use App\Livewire\Modals\RevokeApiToken;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Livewire\Livewire;
function makeWsUser(): array
{
$user = User::factory()->create();
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
WorkspaceMember::create([
'workspace_id' => $ws->id,
'user_id' => $user->id,
'role' => 'owner',
'joined_at' => now(),
]);
return [$user, $ws];
}
// ConfirmAction
it('renders confirm modal', function () {
[$user] = makeWsUser();
Livewire::actingAs($user)->test(ConfirmAction::class, [
'title' => 'Are you sure?',
'message' => 'This cannot be undone.',
'confirmEvent' => 'testConfirmed',
])->assertSee('Are you sure?');
});
it('dispatches confirmEvent on confirm', function () {
[$user] = makeWsUser();
Livewire::actingAs($user)->test(ConfirmAction::class, [
'confirmEvent' => 'testConfirmed',
])->call('confirm')
->assertDispatched('testConfirmed');
});
// InviteMember
it('renders invite member modal', function () {
[$user, $ws] = makeWsUser();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(InviteMember::class)
->assertSee('Invite Team Member');
});
it('validates email on invite member', function () {
[$user, $ws] = makeWsUser();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(InviteMember::class)
->set('email', 'not-an-email')
->call('invite')
->assertHasErrors(['email']);
});
it('creates invitation and dispatches memberInvited', function () {
[$user, $ws] = makeWsUser();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(InviteMember::class)
->set('email', 'new@example.com')
->set('role', 'editor')
->call('invite')
->assertDispatched('memberInvited');
expect(WorkspaceInvitation::where('workspace_id', $ws->id)->where('email', 'new@example.com')->exists())->toBeTrue();
});
// EditMemberRole
it('renders edit member role and updates role', function () {
[$owner, $ws] = makeWsUser();
$member = User::factory()->create();
$membership = WorkspaceMember::create([
'workspace_id' => $ws->id,
'user_id' => $member->id,
'role' => 'editor',
'joined_at' => now(),
]);
app()->instance('current_workspace', $ws);
Livewire::actingAs($owner)->test(EditMemberRole::class, ['memberId' => $membership->id])
->assertSee('Change Role')
->set('role', 'admin')
->call('save')
->assertDispatched('memberRoleUpdated');
expect($membership->fresh()->role)->toBe('admin');
});
it('rejects edit member role for wrong workspace', function () {
[$owner, $ws] = makeWsUser();
app()->instance('current_workspace', $ws);
$otherOwner = User::factory()->create();
$otherWs = Workspace::factory()->create(['owner_id' => $otherOwner->id]);
$membership = WorkspaceMember::create([
'workspace_id' => $otherWs->id,
'user_id' => $otherOwner->id,
'role' => 'owner',
'joined_at' => now(),
]);
expect(fn () => Livewire::actingAs($owner)->test(EditMemberRole::class, ['memberId' => $membership->id]))
->toThrow(ModelNotFoundException::class);
});
// RemoveMember
it('removes member and dispatches memberRemoved', function () {
[$owner, $ws] = makeWsUser();
$member = User::factory()->create();
$membership = WorkspaceMember::create([
'workspace_id' => $ws->id,
'user_id' => $member->id,
'role' => 'editor',
'joined_at' => now(),
]);
app()->instance('current_workspace', $ws);
Livewire::actingAs($owner)->test(RemoveMember::class, ['memberId' => $membership->id])
->call('remove')
->assertDispatched('memberRemoved');
expect(WorkspaceMember::find($membership->id))->toBeNull();
});
// CreateApiToken
it('renders create api token modal', function () {
[$user] = makeWsUser();
Livewire::actingAs($user)->test(CreateApiToken::class)
->assertSee('Create API Token');
});
it('creates token and shows it once', function () {
[$user] = makeWsUser();
$component = Livewire::actingAs($user)->test(CreateApiToken::class)
->set('name', 'Test token')
->call('create')
->assertDispatched('apiTokenCreated');
expect($component->get('plainTextToken'))->not->toBeNull();
});
it('validates token name is required', function () {
[$user] = makeWsUser();
Livewire::actingAs($user)->test(CreateApiToken::class)
->call('create')
->assertHasErrors(['name' => 'required']);
});
// RevokeApiToken
it('revokes token and dispatches apiTokenRevoked', function () {
[$user] = makeWsUser();
$token = $user->createToken('My token');
$pat = $token->accessToken;
Livewire::actingAs($user)->test(RevokeApiToken::class, ['tokenId' => $pat->id])
->call('revoke')
->assertDispatched('apiTokenRevoked');
expect(\Laravel\Sanctum\PersonalAccessToken::find($pat->id))->toBeNull();
});
// CreateWebhook
it('creates webhook and dispatches webhookCreated', function () {
[$user, $ws] = makeWsUser();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateWebhook::class)
->set('url', 'https://example.com/hook')
->set('selectedEvents', ['link.clicked'])
->call('save')
->assertDispatched('webhookCreated');
expect(Webhook::where('workspace_id', $ws->id)->where('url', 'https://example.com/hook')->exists())->toBeTrue();
});
it('validates url and events on create webhook', function () {
[$user, $ws] = makeWsUser();
app()->instance('current_workspace', $ws);
Livewire::actingAs($user)->test(CreateWebhook::class)
->call('save')
->assertHasErrors(['url', 'selectedEvents']);
});
// DeleteWebhook
it('deletes webhook and dispatches webhookDeleted', function () {
[$user, $ws] = makeWsUser();
app()->instance('current_workspace', $ws);
$webhook = Webhook::factory()->create(['workspace_id' => $ws->id]);
Livewire::actingAs($user)->test(DeleteWebhook::class, ['webhookId' => $webhook->id])
->call('delete')
->assertDispatched('webhookDeleted');
expect(Webhook::find($webhook->id))->toBeNull();
});
// CreateWorkspace
it('creates workspace and dispatches workspaceCreated', function () {
[$user] = makeWsUser();
Livewire::actingAs($user)->test(CreateWorkspace::class)
->set('name', 'New Workspace')
->call('create')
->assertDispatched('workspaceCreated');
expect(Workspace::where('owner_id', $user->id)->where('name', 'New Workspace')->exists())->toBeTrue();
});
it('validates workspace name is required', function () {
[$user] = makeWsUser();
Livewire::actingAs($user)->test(CreateWorkspace::class)
->call('create')
->assertHasErrors(['name' => 'required']);
});
// DeleteWorkspace
it('requires confirmation to delete workspace', function () {
[$user, $ws] = makeWsUser();
Livewire::actingAs($user)->test(DeleteWorkspace::class, ['workspaceUlid' => $ws->ulid])
->set('confirmation', 'Wrong name')
->call('delete')
->assertHasErrors(['confirmation']);
expect(Workspace::find($ws->id))->not->toBeNull();
});
it('soft-deletes workspace with correct confirmation', function () {
[$user, $ws] = makeWsUser();
Livewire::actingAs($user)->test(DeleteWorkspace::class, ['workspaceUlid' => $ws->ulid])
->set('confirmation', $ws->name)
->call('delete')
->assertDispatched('workspaceDeleted');
expect(Workspace::withTrashed()->find($ws->id)->deleted_at)->not->toBeNull();
});