diff --git a/app/Domains/Webhook/Models/Webhook.php b/app/Domains/Webhook/Models/Webhook.php index 7ca32bd..f24d182 100644 --- a/app/Domains/Webhook/Models/Webhook.php +++ b/app/Domains/Webhook/Models/Webhook.php @@ -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 */ - use HasFactory, SoftDeletes; + use HasFactory; protected $guarded = ['id']; diff --git a/app/Livewire/Modals/ConfirmAction.php b/app/Livewire/Modals/ConfirmAction.php new file mode 100644 index 0000000..13c8908 --- /dev/null +++ b/app/Livewire/Modals/ConfirmAction.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/app/Livewire/Modals/CreateApiToken.php b/app/Livewire/Modals/CreateApiToken.php new file mode 100644 index 0000000..0f676f0 --- /dev/null +++ b/app/Livewire/Modals/CreateApiToken.php @@ -0,0 +1,38 @@ + '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'); + } +} diff --git a/app/Livewire/Modals/CreateLink.php b/app/Livewire/Modals/CreateLink.php index 9196b6c..3fd88c2 100644 --- a/app/Livewire/Modals/CreateLink.php +++ b/app/Livewire/Modals/CreateLink.php @@ -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 diff --git a/app/Livewire/Modals/CreateWebhook.php b/app/Livewire/Modals/CreateWebhook.php new file mode 100644 index 0000000..c127209 --- /dev/null +++ b/app/Livewire/Modals/CreateWebhook.php @@ -0,0 +1,53 @@ + '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'); + } +} diff --git a/app/Livewire/Modals/CreateWorkspace.php b/app/Livewire/Modals/CreateWorkspace.php new file mode 100644 index 0000000..7ee99e1 --- /dev/null +++ b/app/Livewire/Modals/CreateWorkspace.php @@ -0,0 +1,38 @@ + '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'); + } +} diff --git a/app/Livewire/Modals/DeleteLink.php b/app/Livewire/Modals/DeleteLink.php new file mode 100644 index 0000000..18b8745 --- /dev/null +++ b/app/Livewire/Modals/DeleteLink.php @@ -0,0 +1,46 @@ +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'); + } +} diff --git a/app/Livewire/Modals/DeleteWebhook.php b/app/Livewire/Modals/DeleteWebhook.php new file mode 100644 index 0000000..8d48acb --- /dev/null +++ b/app/Livewire/Modals/DeleteWebhook.php @@ -0,0 +1,46 @@ +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'); + } +} diff --git a/app/Livewire/Modals/DeleteWorkspace.php b/app/Livewire/Modals/DeleteWorkspace.php new file mode 100644 index 0000000..79bf924 --- /dev/null +++ b/app/Livewire/Modals/DeleteWorkspace.php @@ -0,0 +1,60 @@ +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'); + } +} diff --git a/app/Livewire/Modals/EditLink.php b/app/Livewire/Modals/EditLink.php new file mode 100644 index 0000000..ed85691 --- /dev/null +++ b/app/Livewire/Modals/EditLink.php @@ -0,0 +1,66 @@ +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'); + } +} diff --git a/app/Livewire/Modals/EditMemberRole.php b/app/Livewire/Modals/EditMemberRole.php new file mode 100644 index 0000000..9ebaf93 --- /dev/null +++ b/app/Livewire/Modals/EditMemberRole.php @@ -0,0 +1,57 @@ + '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'); + } +} diff --git a/app/Livewire/Modals/InviteMember.php b/app/Livewire/Modals/InviteMember.php new file mode 100644 index 0000000..2857e01 --- /dev/null +++ b/app/Livewire/Modals/InviteMember.php @@ -0,0 +1,49 @@ + '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'); + } +} diff --git a/app/Livewire/Modals/RemoveMember.php b/app/Livewire/Modals/RemoveMember.php new file mode 100644 index 0000000..20da9f3 --- /dev/null +++ b/app/Livewire/Modals/RemoveMember.php @@ -0,0 +1,46 @@ +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'); + } +} diff --git a/app/Livewire/Modals/RevokeApiToken.php b/app/Livewire/Modals/RevokeApiToken.php new file mode 100644 index 0000000..e8664a4 --- /dev/null +++ b/app/Livewire/Modals/RevokeApiToken.php @@ -0,0 +1,45 @@ +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'); + } +} diff --git a/resources/views/components/modal-container.blade.php b/resources/views/components/modal-container.blade.php index 67c0edd..2107f29 100644 --- a/resources/views/components/modal-container.blade.php +++ b/resources/views/components/modal-container.blade.php @@ -1,29 +1 @@ -
- -
+ diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index e74ba6a..fa8e3d4 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -143,12 +143,14 @@
No workspaces yet
@endforelse diff --git a/resources/views/livewire/modals/confirm-action.blade.php b/resources/views/livewire/modals/confirm-action.blade.php new file mode 100644 index 0000000..4a7b441 --- /dev/null +++ b/resources/views/livewire/modals/confirm-action.blade.php @@ -0,0 +1,26 @@ +
+
+ @if($danger) +
+ + + +
+ @endif +
+

{{ $title }}

+

{{ $message }}

+
+
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/create-api-token.blade.php b/resources/views/livewire/modals/create-api-token.blade.php new file mode 100644 index 0000000..ff7d3e7 --- /dev/null +++ b/resources/views/livewire/modals/create-api-token.blade.php @@ -0,0 +1,43 @@ +
+ @if($plainTextToken) +

Token Created

+

Copy this token now — it won't be shown again.

+ +
+ {{ $plainTextToken }} + +
+ +
+ +
+ @else +

Create API Token

+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + +
+ @endif +
diff --git a/resources/views/livewire/modals/create-link.blade.php b/resources/views/livewire/modals/create-link.blade.php index f06a448..770ec77 100644 --- a/resources/views/livewire/modals/create-link.blade.php +++ b/resources/views/livewire/modals/create-link.blade.php @@ -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 - diff --git a/resources/views/livewire/modals/create-webhook.blade.php b/resources/views/livewire/modals/create-webhook.blade.php new file mode 100644 index 0000000..2673ea9 --- /dev/null +++ b/resources/views/livewire/modals/create-webhook.blade.php @@ -0,0 +1,44 @@ +
+

Create Webhook

+ +
+
+ + + @error('url')

{{ $message }}

@enderror +
+ +
+ + +
+ +
+ +
+ @foreach($availableEvents as $event => $label) + + @endforeach +
+ @error('selectedEvents')

{{ $message }}

@enderror +
+
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/create-workspace.blade.php b/resources/views/livewire/modals/create-workspace.blade.php new file mode 100644 index 0000000..1b7a6d8 --- /dev/null +++ b/resources/views/livewire/modals/create-workspace.blade.php @@ -0,0 +1,21 @@ +
+

New Workspace

+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/delete-link.blade.php b/resources/views/livewire/modals/delete-link.blade.php new file mode 100644 index 0000000..b903116 --- /dev/null +++ b/resources/views/livewire/modals/delete-link.blade.php @@ -0,0 +1,26 @@ +
+
+
+ + + +
+
+

Delete Link

+

+ Delete {{ $linkTitle }}? This cannot be undone. All click analytics will be permanently removed. +

+
+
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/delete-webhook.blade.php b/resources/views/livewire/modals/delete-webhook.blade.php new file mode 100644 index 0000000..c4e51bc --- /dev/null +++ b/resources/views/livewire/modals/delete-webhook.blade.php @@ -0,0 +1,26 @@ +
+
+
+ + + +
+
+

Delete Webhook

+

+ Delete endpoint {{ $webhookUrl }}? Delivery history will be removed. +

+
+
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/delete-workspace.blade.php b/resources/views/livewire/modals/delete-workspace.blade.php new file mode 100644 index 0000000..98d6d0b --- /dev/null +++ b/resources/views/livewire/modals/delete-workspace.blade.php @@ -0,0 +1,35 @@ +
+
+
+ + + +
+
+

Delete Workspace

+

+ This permanently deletes {{ $workspaceName }} and all its links, analytics, domains, and members. This action cannot be undone. +

+
+
+ +
+ + + @error('confirmation')

{{ $message }}

@enderror +
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/edit-link.blade.php b/resources/views/livewire/modals/edit-link.blade.php new file mode 100644 index 0000000..1ba8c48 --- /dev/null +++ b/resources/views/livewire/modals/edit-link.blade.php @@ -0,0 +1,48 @@ +
+

Edit Link

+ +
+
+ + + @error('targetUrl')

{{ $message }}

@enderror +
+ +
+ +
+ nimu.li/ + +
+ @error('slug')

{{ $message }}

@enderror +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/edit-member-role.blade.php b/resources/views/livewire/modals/edit-member-role.blade.php new file mode 100644 index 0000000..7ff2883 --- /dev/null +++ b/resources/views/livewire/modals/edit-member-role.blade.php @@ -0,0 +1,25 @@ +
+

Change Role

+

Update role for {{ $memberName }}

+ +
+ + +
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/invite-member.blade.php b/resources/views/livewire/modals/invite-member.blade.php new file mode 100644 index 0000000..97d9916 --- /dev/null +++ b/resources/views/livewire/modals/invite-member.blade.php @@ -0,0 +1,35 @@ +
+

Invite Team Member

+ +
+
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + +
+
+ +

An invitation link will be sent to this address and expires in 7 days.

+ +
+ + +
+
diff --git a/resources/views/livewire/modals/remove-member.blade.php b/resources/views/livewire/modals/remove-member.blade.php new file mode 100644 index 0000000..07f259e --- /dev/null +++ b/resources/views/livewire/modals/remove-member.blade.php @@ -0,0 +1,26 @@ +
+
+
+ + + +
+
+

Remove Member

+

+ Remove {{ $memberName }} from this workspace? They will lose access immediately. +

+
+
+ +
+ + +
+
diff --git a/resources/views/livewire/modals/revoke-api-token.blade.php b/resources/views/livewire/modals/revoke-api-token.blade.php new file mode 100644 index 0000000..be01fb4 --- /dev/null +++ b/resources/views/livewire/modals/revoke-api-token.blade.php @@ -0,0 +1,26 @@ +
+
+
+ + + +
+
+

Revoke Token

+

+ Revoke {{ $tokenName }}? Any integrations using this token will stop working immediately. +

+
+
+ +
+ + +
+
diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php new file mode 100644 index 0000000..a739026 --- /dev/null +++ b/resources/views/vendor/wire-elements-modal/modal.blade.php @@ -0,0 +1,53 @@ +
+ @isset($jsPath) + + @endisset + @isset($cssPath) + + @endisset + + +
diff --git a/tests/Feature/Livewire/LinksModalsTest.php b/tests/Feature/Livewire/LinksModalsTest.php new file mode 100644 index 0000000..fd0d94c --- /dev/null +++ b/tests/Feature/Livewire/LinksModalsTest.php @@ -0,0 +1,168 @@ +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); +}); diff --git a/tests/Feature/Livewire/WorkspaceModalsTest.php b/tests/Feature/Livewire/WorkspaceModalsTest.php new file mode 100644 index 0000000..50e6a0a --- /dev/null +++ b/tests/Feature/Livewire/WorkspaceModalsTest.php @@ -0,0 +1,271 @@ +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(); +});