#591 - notification-channels

This commit is contained in:
Saeed Vaziry
2025-05-19 20:05:38 +02:00
parent 563b9c5909
commit cdc012c270
16 changed files with 611 additions and 100 deletions

View File

@ -5,6 +5,7 @@
use App\Models\NotificationChannel; use App\Models\NotificationChannel;
use App\Models\User; use App\Models\User;
use Exception; use Exception;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -17,10 +18,12 @@ class AddChannel
*/ */
public function add(User $user, array $input): void public function add(User $user, array $input): void
{ {
Validator::make($input, self::rules($input))->validate();
$channel = new NotificationChannel([ $channel = new NotificationChannel([
'user_id' => $user->id, 'user_id' => $user->id,
'provider' => $input['provider'], 'provider' => $input['provider'],
'label' => $input['label'], 'label' => $input['name'],
'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id, 'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id,
]); ]);
$channel->data = $channel->provider()->createData($input); $channel->data = $channel->provider()->createData($input);
@ -63,7 +66,7 @@ public static function rules(array $input): array
'required', 'required',
Rule::in(config('core.notification_channels_providers')), Rule::in(config('core.notification_channels_providers')),
], ],
'label' => 'required', 'name' => 'required',
]; ];
return array_merge($rules, self::providerRules($input)); return array_merge($rules, self::providerRules($input));

View File

@ -13,7 +13,7 @@ class EditChannel
public function edit(NotificationChannel $notificationChannel, User $user, array $input): void public function edit(NotificationChannel $notificationChannel, User $user, array $input): void
{ {
$notificationChannel->fill([ $notificationChannel->fill([
'label' => $input['label'], 'label' => $input['name'],
'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id, 'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id,
]); ]);
$notificationChannel->save(); $notificationChannel->save();
@ -26,7 +26,7 @@ public function edit(NotificationChannel $notificationChannel, User $user, array
public static function rules(array $input): array public static function rules(array $input): array
{ {
return [ return [
'label' => 'required', 'name' => 'required',
]; ];
} }
} }

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Actions\NotificationChannels\AddChannel;
use App\Actions\NotificationChannels\EditChannel;
use App\Http\Resources\NotificationChannelResource;
use App\Models\NotificationChannel;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\RouteAttributes\Attributes\Delete;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Patch;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
#[Prefix('settings/notification-channels')]
#[Middleware(['auth'])]
class NotificationChannelController extends Controller
{
#[Get('/', name: 'notification-channels')]
public function index(): Response
{
$this->authorize('viewAny', NotificationChannel::class);
return Inertia::render('notification-channels/index', [
'notificationChannels' => NotificationChannelResource::collection(NotificationChannel::getByProjectId(user()->current_project_id)->simplePaginate(config('web.pagination_size'))),
]);
}
#[Get('/json', name: 'notification-channels.json')]
public function json(): ResourceCollection
{
$this->authorize('viewAny', NotificationChannel::class);
return NotificationChannelResource::collection(NotificationChannel::getByProjectId(user()->current_project_id)->get());
}
#[Post('/', name: 'notification-channels.store')]
public function store(Request $request): RedirectResponse
{
$this->authorize('create', NotificationChannel::class);
app(AddChannel::class)->add(user(), $request->all());
return back()->with('success', 'Notification channel created.');
}
#[Patch('/{notificationChannel}', name: 'notification-channels.update')]
public function update(Request $request, NotificationChannel $notificationChannel): RedirectResponse
{
$this->authorize('update', $notificationChannel);
app(EditChannel::class)->edit($notificationChannel, user(), $request->all());
return back()->with('success', 'Notification channel updated.');
}
#[Delete('{notificationChannel}', name: 'notification-channels.destroy')]
public function destroy(NotificationChannel $notificationChannel): RedirectResponse
{
$this->authorize('delete', $notificationChannel);
$notificationChannel->delete();
return to_route('notification-channels')->with('success', 'Notification channel deleted.');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use App\Models\NotificationChannel;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin NotificationChannel */
class NotificationChannelResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'project_id' => $this->project_id,
'global' => is_null($this->project_id),
'name' => $this->label,
'provider' => $this->provider,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Notifications\NotificationInterface; use App\Notifications\NotificationInterface;
use Database\Factories\NotificationChannelFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -14,11 +15,11 @@
* @property array<string, mixed> $data * @property array<string, mixed> $data
* @property string $label * @property string $label
* @property bool $connected * @property bool $connected
* @property int $project_id * @property ?int $project_id
*/ */
class NotificationChannel extends AbstractModel class NotificationChannel extends AbstractModel
{ {
/** @use HasFactory<\Database\Factories\NotificationChannelFactory> */ /** @use HasFactory<NotificationChannelFactory> */
use HasFactory; use HasFactory;
use Notifiable; use Notifiable;

View File

@ -520,6 +520,12 @@
\App\Enums\NotificationChannel::EMAIL => \App\NotificationChannels\Email::class, \App\Enums\NotificationChannel::EMAIL => \App\NotificationChannels\Email::class,
\App\Enums\NotificationChannel::TELEGRAM => \App\NotificationChannels\Telegram::class, \App\Enums\NotificationChannel::TELEGRAM => \App\NotificationChannels\Telegram::class,
], ],
'notification_channels_providers_custom_fields' => [
\App\Enums\NotificationChannel::SLACK => ['webhook_url'],
\App\Enums\NotificationChannel::DISCORD => ['webhook_url'],
\App\Enums\NotificationChannel::EMAIL => ['email'],
\App\Enums\NotificationChannel::TELEGRAM => ['bot_token', 'chat_id'],
],
/* /*
* storage providers * storage providers

View File

@ -46,7 +46,7 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>

View File

@ -59,7 +59,7 @@ function SheetContent({
{...props} {...props}
> >
{children} {children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>

View File

@ -1,5 +1,5 @@
import { type BreadcrumbItem, type NavItem } from '@/types'; import { type BreadcrumbItem, type NavItem } from '@/types';
import { CloudIcon, CodeIcon, DatabaseIcon, ListIcon, UserIcon, UsersIcon } from 'lucide-react'; import { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, ListIcon, UserIcon, UsersIcon } from 'lucide-react';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import Layout from '@/layouts/app/layout'; import Layout from '@/layouts/app/layout';
@ -34,6 +34,11 @@ const sidebarNavItems: NavItem[] = [
href: route('storage-providers'), href: route('storage-providers'),
icon: DatabaseIcon, icon: DatabaseIcon,
}, },
{
title: 'Notification Channels',
href: route('notification-channels'),
icon: BellIcon,
},
]; ];
export default function SettingsLayout({ children, breadcrumbs }: { children: ReactNode; breadcrumbs?: BreadcrumbItem[] }) { export default function SettingsLayout({ children, breadcrumbs }: { children: ReactNode; breadcrumbs?: BreadcrumbItem[] }) {

View File

@ -0,0 +1,185 @@
import { ColumnDef } from '@tanstack/react-table';
import DateTime from '@/components/date-time';
import { NotificationChannel } from '@/types/notification-channel';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { useForm } from '@inertiajs/react';
import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react';
import FormSuccessful from '@/components/form-successful';
import { FormEvent, useState } from 'react';
import InputError from '@/components/ui/input-error';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
function Edit({ notificationChannel }: { notificationChannel: NotificationChannel }) {
const [open, setOpen] = useState(false);
const form = useForm({
name: notificationChannel.name,
global: notificationChannel.global,
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.patch(route('notification-channels.update', notificationChannel.id));
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Edit</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit {notificationChannel.name}</DialogTitle>
<DialogDescription className="sr-only">Edit notification channel</DialogDescription>
</DialogHeader>
<Form id="edit-notification-channel-form" className="p-4" onSubmit={submit}>
<FormFields>
<FormField>
<Label htmlFor="name">Name</Label>
<Input type="text" id="name" name="name" value={form.data.name} onChange={(e) => form.setData('name', e.target.value)} />
<InputError message={form.errors.name} />
</FormField>
<FormField>
<div className="flex items-center space-x-3">
<Checkbox id="global" name="global" checked={form.data.global} onClick={() => form.setData('global', !form.data.global)} />
<Label htmlFor="global">Is global (accessible in all projects)</Label>
</div>
<InputError message={form.errors.global} />
</FormField>
</FormFields>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="edit-notification-channel-form" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Delete({ notificationChannel }: { notificationChannel: NotificationChannel }) {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = () => {
form.delete(route('notification-channels.destroy', notificationChannel.id), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {notificationChannel.name}</DialogTitle>
<DialogDescription className="sr-only">Delete notification channel</DialogDescription>
</DialogHeader>
<div className="space-y-2 p-4">
<p>
Are you sure you want to delete <strong>{notificationChannel.name}</strong>?
</p>
<InputError message={form.errors.provider} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const columns: ColumnDef<NotificationChannel>[] = [
{
accessorKey: 'id',
header: 'ID',
enableColumnFilter: true,
enableSorting: true,
enableHiding: true,
},
{
accessorKey: 'provider',
header: 'Provider',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'name',
header: 'Name',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'global',
header: 'Global',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <div>{row.original.global ? <Badge variant="success">yes</Badge> : <Badge variant="danger">no</Badge>}</div>;
},
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Edit notificationChannel={row.original} />
<DropdownMenuSeparator />
<Delete notificationChannel={row.original} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];

View File

@ -0,0 +1,158 @@
import { LoaderCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { useForm, usePage } from '@inertiajs/react';
import { FormEventHandler, ReactNode, useEffect, useState } from 'react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import InputError from '@/components/ui/input-error';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { SharedData } from '@/types';
import { Checkbox } from '@/components/ui/checkbox';
type NotificationChannelForm = {
provider: string;
name: string;
global: boolean;
};
export default function ConnectNotificationChannel({
providers,
defaultProvider,
onProviderAdded,
children,
}: {
providers: string[];
defaultProvider?: string;
onProviderAdded?: () => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const page = usePage<SharedData>();
const form = useForm<Required<NotificationChannelForm>>({
provider: 'email',
name: '',
global: false,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
form.post(route('notification-channels.store'), {
onSuccess: () => {
setOpen(false);
if (onProviderAdded) {
onProviderAdded();
}
},
});
};
useEffect(() => {
form.setData('provider', defaultProvider ?? 'email');
}, [defaultProvider]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Connect to notification channel</DialogTitle>
<DialogDescription className="sr-only">Connect to a new notification channel</DialogDescription>
</DialogHeader>
<Form id="create-notification-channel-form" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="provider">Provider</Label>
<Select
value={form.data.provider}
onValueChange={(value) => {
form.setData('provider', value);
form.clearErrors();
}}
>
<SelectTrigger id="provider">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<InputError message={form.errors.provider} />
</FormField>
<FormField>
<Label htmlFor="name">Name</Label>
<Input
type="text"
name="name"
id="name"
placeholder="Name"
value={form.data.name}
onChange={(e) => form.setData('name', e.target.value)}
/>
<InputError message={form.errors.name} />
</FormField>
<div
className={
page.props.configs.notification_channels_providers_custom_fields[form.data.provider].length > 1
? 'grid grid-cols-2 items-start gap-6'
: ''
}
>
{page.props.configs.notification_channels_providers_custom_fields[form.data.provider]?.map((item: string) => (
<FormField key={item}>
<Label htmlFor={item} className="capitalize">
{item.replaceAll('_', ' ')}
</Label>
<Input
type="text"
name={item}
id={item}
value={(form.data[item as keyof NotificationChannelForm] as string) ?? ''}
onChange={(e) => form.setData(item as keyof NotificationChannelForm, e.target.value)}
/>
<InputError message={form.errors[item as keyof NotificationChannelForm]} />
</FormField>
))}
</div>
<FormField>
<div className="flex items-center space-x-3">
<Checkbox id="global" name="global" checked={form.data.global} onClick={() => form.setData('global', !form.data.global)} />
<Label htmlFor="global">Is global (accessible in all projects)</Label>
</div>
<InputError message={form.errors.global} />
</FormField>
</FormFields>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button type="button" onClick={submit} disabled={form.processing}>
{form.processing && <LoaderCircle className="animate-spin" />}
Connect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,40 @@
import SettingsLayout from '@/layouts/settings/layout';
import { Head, usePage } from '@inertiajs/react';
import Container from '@/components/container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import React from 'react';
import ConnectNotificationChannel from '@/pages/notification-channels/components/connect-notification-channel';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/notification-channels/components/columns';
import { NotificationChannel } from '@/types/notification-channel';
import { Configs } from '@/types';
type Page = {
notificationChannels: {
data: NotificationChannel[];
};
configs: Configs;
};
export default function NotificationChannels() {
const page = usePage<Page>();
return (
<SettingsLayout>
<Head title="Notification Channels" />
<Container className="max-w-5xl">
<div className="flex items-start justify-between">
<Heading title="Notification Channels" description="Here you can manage all of the notification channel connectinos" />
<div className="flex items-center gap-2">
<ConnectNotificationChannel providers={page.props.configs.notification_channels_providers}>
<Button>Connect</Button>
</ConnectNotificationChannel>
</div>
</div>
<DataTable columns={columns} data={page.props.notificationChannels.data} />
</Container>
</SettingsLayout>
);
}

View File

@ -66,7 +66,7 @@ export default function ConnectStorageProvider({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Connect to storage provider</DialogTitle> <DialogTitle>Connect to storage provider</DialogTitle>
<DialogDescription className="sr-only">Connect to a new storage provider</DialogDescription> <DialogDescription className="sr-only">Connect to a new storage provider</DialogDescription>
@ -109,6 +109,11 @@ export default function ConnectStorageProvider({
/> />
<InputError message={form.errors.name} /> <InputError message={form.errors.name} />
</FormField> </FormField>
<div
className={
page.props.configs.storage_providers_custom_fields[form.data.provider].length > 1 ? 'grid grid-cols-2 items-start gap-6' : ''
}
>
{page.props.configs.storage_providers_custom_fields[form.data.provider]?.map((item: string) => ( {page.props.configs.storage_providers_custom_fields[form.data.provider]?.map((item: string) => (
<FormField key={item}> <FormField key={item}>
<Label htmlFor={item} className="capitalize"> <Label htmlFor={item} className="capitalize">
@ -124,6 +129,7 @@ export default function ConnectStorageProvider({
<InputError message={form.errors[item as keyof StorageProviderForm]} /> <InputError message={form.errors[item as keyof StorageProviderForm]} />
</FormField> </FormField>
))} ))}
</div>
<FormField> <FormField>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Checkbox id="global" name="global" checked={form.data.global} onClick={() => form.setData('global', !form.data.global)} /> <Checkbox id="global" name="global" checked={form.data.global} onClick={() => form.setData('global', !form.data.global)} />

View File

@ -41,6 +41,10 @@ export interface Configs {
storage_providers_custom_fields: { storage_providers_custom_fields: {
[provider: string]: string[]; [provider: string]: string[];
}; };
notification_channels_providers: string[];
notification_channels_providers_custom_fields: {
[provider: string]: string[];
};
operating_systems: string[]; operating_systems: string[];
service_versions: { service_versions: {
[service: string]: string[]; [service: string]: string[];

View File

@ -0,0 +1,11 @@
export interface NotificationChannel {
id: number;
project_id?: number;
global: boolean;
name: string;
provider: string;
created_at: string;
updated_at: string;
[key: string]: unknown;
}

View File

@ -3,12 +3,10 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\NotificationChannel; use App\Enums\NotificationChannel;
use App\Web\Pages\Settings\NotificationChannels\Index;
use App\Web\Pages\Settings\NotificationChannels\Widgets\NotificationChannelsList;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Livewire; use Inertia\Testing\AssertableInertia;
use Tests\TestCase; use Tests\TestCase;
class NotificationChannelsTest extends TestCase class NotificationChannelsTest extends TestCase
@ -20,14 +18,13 @@ public function test_add_email_channel(): void
{ {
$this->actingAs($this->user); $this->actingAs($this->user);
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::EMAIL, 'provider' => NotificationChannel::EMAIL,
'email' => 'email@example.com', 'email' => 'email@example.com',
'label' => 'Email', 'name' => 'Email',
'global' => true, 'global' => true,
]) ])
->assertSuccessful(); ->assertSessionDoesntHaveErrors();
/** @var \App\Models\NotificationChannel $channel */ /** @var \App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
@ -47,16 +44,14 @@ public function test_cannot_add_email_channel(): void
$this->actingAs($this->user); $this->actingAs($this->user);
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::EMAIL, 'provider' => NotificationChannel::EMAIL,
'email' => 'email@example.com', 'email' => 'email@example.com',
'label' => 'Email', 'name' => 'Email',
'global' => true, 'global' => true,
]) ]);
->assertNotified('Could not connect! Make sure you configured `.env` file correctly.');
/** @var \App\Models\NotificationChannel $channel */ /** @var ?\App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
->where('provider', NotificationChannel::EMAIL) ->where('provider', NotificationChannel::EMAIL)
->where('label', 'Email') ->where('label', 'Email')
@ -71,13 +66,12 @@ public function test_add_slack_channel(): void
Http::fake(); Http::fake();
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::SLACK, 'provider' => NotificationChannel::SLACK,
'webhook_url' => 'https://hooks.slack.com/services/123/token', 'webhook_url' => 'https://hooks.slack.com/services/123/token',
'label' => 'Slack', 'name' => 'Slack',
]) ])
->assertSuccessful(); ->assertSessionDoesntHaveErrors();
/** @var \App\Models\NotificationChannel $channel */ /** @var \App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
@ -96,15 +90,16 @@ public function test_cannot_add_slack_channel(): void
'slack.com/*' => Http::response(['ok' => false], 401), 'slack.com/*' => Http::response(['ok' => false], 401),
]); ]);
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::SLACK, 'provider' => NotificationChannel::SLACK,
'webhook_url' => 'https://hooks.slack.com/services/123/token', 'webhook_url' => 'https://hooks.slack.com/services/123/token',
'label' => 'Slack', 'name' => 'Slack',
]) ])
->assertNotified('Could not connect'); ->assertSessionHasErrors([
'provider' => 'Could not connect',
]);
/** @var \App\Models\NotificationChannel $channel */ /** @var ?\App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
->where('provider', NotificationChannel::SLACK) ->where('provider', NotificationChannel::SLACK)
->first(); ->first();
@ -118,13 +113,12 @@ public function test_add_discord_channel(): void
Http::fake(); Http::fake();
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::DISCORD, 'provider' => NotificationChannel::DISCORD,
'webhook_url' => 'https://discord.com/api/webhooks/123/token', 'webhook_url' => 'https://discord.com/api/webhooks/123/token',
'label' => 'Discord', 'name' => 'Discord',
]) ])
->assertSuccessful(); ->assertSessionDoesntHaveErrors();
/** @var \App\Models\NotificationChannel $channel */ /** @var \App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
@ -143,15 +137,16 @@ public function test_cannot_add_discord_channel(): void
'discord.com/*' => Http::response(['ok' => false], 401), 'discord.com/*' => Http::response(['ok' => false], 401),
]); ]);
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::DISCORD, 'provider' => NotificationChannel::DISCORD,
'webhook_url' => 'https://discord.com/api/webhooks/123/token', 'webhook_url' => 'https://discord.com/api/webhooks/123/token',
'label' => 'Discord', 'name' => 'Slack',
]) ])
->assertNotified('Could not connect'); ->assertSessionHasErrors([
'provider' => 'Could not connect',
]);
/** @var \App\Models\NotificationChannel $channel */ /** @var ?\App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
->where('provider', NotificationChannel::DISCORD) ->where('provider', NotificationChannel::DISCORD)
->first(); ->first();
@ -165,14 +160,13 @@ public function test_add_telegram_channel(): void
Http::fake(); Http::fake();
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::TELEGRAM, 'provider' => NotificationChannel::TELEGRAM,
'bot_token' => 'token', 'bot_token' => 'token',
'chat_id' => '123', 'chat_id' => '123',
'label' => 'Telegram', 'name' => 'Telegram',
]) ])
->assertSuccessful(); ->assertSessionDoesntHaveErrors();
/** @var \App\Models\NotificationChannel $channel */ /** @var \App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
@ -192,16 +186,17 @@ public function test_cannot_add_telegram_channel(): void
'api.telegram.org/*' => Http::response(['ok' => false], 401), 'api.telegram.org/*' => Http::response(['ok' => false], 401),
]); ]);
Livewire::test(Index::class) $this->post(route('notification-channels.store'), [
->callAction('add', [
'provider' => NotificationChannel::TELEGRAM, 'provider' => NotificationChannel::TELEGRAM,
'bot_token' => 'token', 'bot_token' => 'token',
'chat_id' => '123', 'chat_id' => '123',
'label' => 'Telegram', 'name' => 'Telegram',
]) ])
->assertNotified('Could not connect'); ->assertSessionHasErrors([
'provider' => 'Could not connect',
]);
/** @var \App\Models\NotificationChannel $channel */ /** @var ?\App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
->where('provider', NotificationChannel::TELEGRAM) ->where('provider', NotificationChannel::TELEGRAM)
->first(); ->first();
@ -213,12 +208,10 @@ public function test_see_channels_list(): void
{ {
$this->actingAs($this->user); $this->actingAs($this->user);
/** @var \App\Models\NotificationChannel $channel */ \App\Models\NotificationChannel::factory()->create();
$channel = \App\Models\NotificationChannel::factory()->create();
$this->get(Index::getUrl()) $this->get(route('notification-channels'))
->assertSuccessful() ->assertInertia(fn (AssertableInertia $page) => $page->component('notification-channels/index'));
->assertSee($channel->label);
} }
public function test_delete_channel(): void public function test_delete_channel(): void
@ -227,9 +220,9 @@ public function test_delete_channel(): void
$channel = \App\Models\NotificationChannel::factory()->create(); $channel = \App\Models\NotificationChannel::factory()->create();
Livewire::test(NotificationChannelsList::class) $this->delete(route('notification-channels.destroy', [
->callTableAction('delete', $channel->id) 'notificationChannel' => $channel->id,
->assertSuccessful(); ]));
$this->assertDatabaseMissing('notification_channels', [ $this->assertDatabaseMissing('notification_channels', [
'id' => $channel->id, 'id' => $channel->id,