Integracje
View on GitHubDodaj nową integrację
Przećwiczony przykład: zamiana Slacka z zablokowanej karty "coming soon" w katalogu w w pełni działającą integrację, dokładnie tak, jak dziś działa Discord. Slack ma już ikonę marki i wpis w katalogu (z comingSoon: true), więc ten przewodnik pokrywa zarówno "wpis w katalogu już istnieje, trzeba go tylko urealnić" (kroki 1, 4–8), jak i "nie ma jeszcze żadnego wpisu w katalogu" (kroki 2–3, które pominiesz, jeśli — tak jak w przypadku Slacka — już tam są).
Jeśli podpinasz zupełnie nową integrację bez żadnego wpisu w katalogu i bez ikony marki, wykonaj każdy krok po kolei. Jeśli integracja jest już w katalogu jako comingSoon: true (dziś dotyczy to wszystkiego poza Discordem), przejdź od razu do kroku 4.
Krok 1 — Potwierdź/dodaj ikonę marki
Plik: resources/js/Components/Atoms/BrandIcon/BrandIcon.tsx
Każda ikona to wklejone oficjalne dane ścieżki SVG (pochodzące z Simple Icons, na licencji CC0 — nie rysowane ręcznie; svgrepo.com jest chronione przez Cloudflare i nie da się go zescrapować z tego środowiska, więc realne dane ścieżki pobierz zamiast tego z https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/<slug>.svg). Slack ma już swój wpis:
case 'slack':
return (
<svg
role="img"
viewBox="0 0 24 24"
className={className}
fill="#4A154B"
>
<title>Slack</title>
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" />
</svg>
);
Gdybyś dodawał ją od zera, dodaj nową nazwę do unii BrandIconName na górze pliku oraz nowy case w switch, zachowując dokładnie ten sam kształt: role="img", viewBox="0 0 24 24", <title> oraz fill ustawiony na oficjalny hex marki (znajdziesz go w https://raw.githubusercontent.com/simple-icons/simple-icons/develop/data/simple-icons.json) albo na currentColor, jeśli logotyp marki jest z natury monochromatyczny/czarno-biały (tak robią GitHub, Notion, CircleCI — połącz to z accentClassName: 'bg-white/10' we wpisie katalogowym poniżej, żeby był widoczny na ciemnym motywie).
Dodaj odpowiadający case do tablicy names w resources/js/Components/Atoms/BrandIcon/BrandIcon.test.tsx, żeby test.each go pokrył.
Krok 2 — Dodaj/potwierdź wpis w katalogu
Plik: resources/js/types/Integrations.ts
{
id: 'slack',
name: 'Slack',
vendor: 'By Slack Technologies',
category: 'Communication', // jedna z INTEGRATION_CATEGORIES
brand: 'slack', // musi pasować do case'a w BrandIconName
accentClassName: 'bg-[#4A154B]/15', // hex marki przy 15% przezroczystości dla chipa z ikoną
websiteUrl: 'https://slack.com',
description:
'Send notifications and updates directly to your team channels.',
overview:
'Connect Slack to route Orbit notifications into the right channels, so teams can react to project activity without leaving their existing workflow.\n\n**What you get:**\n- Channel-level routing per project\n- Threaded replies that stay in sync with Orbit comments\n- A slash command to create an issue without leaving Slack',
previewSamples: [
{ title: 'Issue #128 assigned to Jane Cooper', time: 'Just now' },
{ title: 'New comment on "Fix login crash"', time: '2m ago' },
],
subOptions: [
{
id: 'issue-activity',
title: 'Issue activity',
description:
'Post a message when an issue is created, assigned, or resolved.',
},
{
id: 'comment-activity',
title: 'Comment activity',
description: 'Post a message whenever someone leaves a comment.',
},
],
comingSoon: true, // <- to jedyna rzecz, którą zmienia krok 4
},
Uwagi do poszczególnych pól:
overviewjest renderowane jako markdown (przezEditableMarkdownw trybie disabled — zobacz przewodnik 4) — używaj\n\nna odstępy między akapitami oraz składni markdown**pogrubienie**/- lista, nie HTML.subOptions[].idmusi dokładnie pasować do kluczy tekstowych, których użyjesz na backendzie (issue-activity,comment-activityalbo nowych — zobacz przewodnik 2). Te id płyną, niezmienione, od interfejsu na froncie aż poProjectIntegration.optionsw bazie danych oraz dopasowanie kategorii wNotifyProjectIntegrationsListener. Popraw pisownię w obu miejscach, inaczej przełącznik po cichu nic nie zrobi.previewSamplesto dwie fikcyjne linijki aktywności pokazywane w bloku podglądu w modalu (WorkspaceSettingsIntegrationPreview) — czysto kosmetyczne, nieczytane przez żaden kod backendu.
Krok 3 — (tylko dla zupełnie nowej integracji) dodaj test dymny Pest/Vitest dla danych katalogu
Nie ma dedykowanego pliku testowego dla samego INTEGRATIONS (jest pokrywany pośrednio przez każdy test komponentu, który z niego renderuje), więc nie trzeba tu nic dodawać poza tym, co już pokrył krok 1 dla ikony.
Krok 4 — Udostępnij ją na backendzie
Plik: app/Services/ProjectIntegrationService.php
Dodaj id integracji do dwóch stałych klasy:
public const array AVAILABLE_INTEGRATIONS = ['discord', 'slack'];
private const array AVAILABLE_OPTIONS = [
'discord' => ['issue-activity', 'comment-activity'],
'slack' => ['issue-activity', 'comment-activity'],
];
Jeśli integracja przyjmuje URL webhooka i chcesz walidację formatu (zalecane — łapie pomyłki przy kopiowaniu wcześnie), dodaj też wyrażenie regularne:
private const array WEBHOOK_URL_PATTERNS = [
'discord' => '/^https:\/\/(canary\.|ptb\.)?discord(app)?\.com\/api\/webhooks\/\d+\/[\w-]+$/',
'slack' => '/^https:\/\/hooks\.slack\.com\/services\/T[0-9A-Z]+\/B[0-9A-Z]+\/[0-9A-Za-z]+$/',
];
Jeśli wzorzec dla danej integracji zostanie pominięty, updateSettings() po prostu pomija dla niej walidację formatu (i tak zapisuje, jakikolwiek string podano) — dodawaj wzorzec dopiero, gdy faktycznie znasz prawdziwy kształt URL-a webhooka tej integracji.
To cała zmiana potrzebna na warstwie "czy ta integracja jest dozwolona, czy może zapisywać ustawienia" — getSettings()/getStatuses() wyprowadzają wszystko inne z AVAILABLE_INTEGRATIONS automatycznie.
Krok 5 — Napisz IntegrationNotifier
Plik: app/Services/Integrations/SlackIntegrationNotifier.php (nowy)
To jest klasa, która zamienia event domenowy na faktyczny format integracji i kolejkuje dostarczenie. Discord chce "embedy"; Slack chce "blocks" — notifier każdej integracji ma własny kształt payloadu, ale wszystkie kończą wysyłaniem tego samego, generycznego SendWebhookNotificationJob.
<?php
namespace App\Services\Integrations;
use App\Contracts\IntegrationNotifier;
use App\Events\CommentAdded;
use App\Events\IssueAssigned;
use App\Events\IssueUnassigned;
use App\Events\IssueUpdated;
use App\Jobs\SendWebhookNotificationJob;
use App\Models\Issue;
use App\Models\ProjectIntegration;
use Illuminate\Support\Str;
class SlackIntegrationNotifier implements IntegrationNotifier
{
public function handle(ProjectIntegration $projectIntegration, object $event): void
{
if (! $projectIntegration->webhook_url) {
return;
}
$text = match (true) {
$event instanceof IssueAssigned => $this->issueAssignedText($event),
$event instanceof IssueUnassigned => $this->issueUnassignedText($event),
$event instanceof IssueUpdated => $this->issueUpdatedText($event),
$event instanceof CommentAdded => $this->commentAddedText($event),
default => null,
};
if (! $text) {
return;
}
SendWebhookNotificationJob::dispatch($projectIntegration->webhook_url, [
'text' => $text,
]);
}
private function issueAssignedText(IssueAssigned $event): string
{
$issue = $event->issue;
$actorName = $event->actor?->name ?? 'Someone';
return "📌 *Issue #$issue->id assigned* — {$event->assignee->name} was assigned to <{$this->buildActionUrl($issue)}|\"$issue->title\"> by $actorName.";
}
private function issueUnassignedText(IssueUnassigned $event): string
{
$issue = $event->issue;
$actorName = $event->actor?->name ?? 'Someone';
return "📤 *Issue #$issue->id unassigned* — {$event->previousAssignee->name} was unassigned from <{$this->buildActionUrl($issue)}|\"$issue->title\"> by $actorName.";
}
private function issueUpdatedText(IssueUpdated $event): ?string
{
if (! $event->actor) {
return null;
}
$issue = $event->issue;
$summary = implode(', ', array_map(fn (array $change) => $change['text'], $event->changes));
return "📝 *Issue #$issue->id updated* by {$event->actor->name} — <{$this->buildActionUrl($issue)}|\"$issue->title\">: $summary";
}
private function commentAddedText(CommentAdded $event): string
{
$issue = $event->issue;
$actorName = $event->actor?->name ?? 'Someone';
$body = Str::limit(trim(strip_tags($event->comment->body ?? '')), 300);
return "💬 *New comment on issue #$issue->id* — $actorName on <{$this->buildActionUrl($issue)}|\"$issue->title\">: $body";
}
private function buildActionUrl(Issue $issue): string
{
return route('projects.show', $issue->project_id).'?issue='.$issue->id;
}
}
Wskazówki do pisania każdego nowego notifiera:
- Zawsze zabezpiecz się warunkiem na
$projectIntegration->webhook_url— wiersz może istnieć (enabled = true) bez jeszcze skonfigurowanego URL-a webhooka, jeśli użytkownik włączył przełącznik, zanim go skonfigurował. - Dopasowuj przez
instanceofdo każdego rodzaju eventu, który Cię interesuje; dla reszty wracaj wcześnie (null/brak wysyłki) — notifier musi obsługiwać tylko eventy istotne dla pod-opcji, z którymi jest sparowany (zobacz mapowanie kategoriiNotifyProjectIntegrationsListenerw przewodniku 3). IssueUpdated::actorjest nullable w systemie typów, mimo że w praktyceIssueServicezawsze odpala go z aktorem — i tak się na to zabezpiecz (zobaczDiscordIntegrationNotifier::issueUpdatedEmbed), żeby przyszły wywołujący nie mógł wywołać NPE w tej klasie.- Właściwe wywołanie HTTP rób z poziomu
SendWebhookNotificationJob, nigdy bezpośrednio w notifierze — dzięki temu dostarczanie każdej integracji jest kolejkowane/ponawiane w ten sam sposób, za darmo.
Krok 6 — Zarejestruj ją
Plik: app/Services/Integrations/IntegrationNotifierRegistry.php
private const array MAP = [
'discord' => DiscordIntegrationNotifier::class,
'slack' => SlackIntegrationNotifier::class,
];
To jedyne miejsce, w którym NotifyProjectIntegrationsListener sprawdza, która klasa obsługuje który klucz integracji — nic więcej nie musi się zmienić, żeby ścieżka dispatchu ją podchwyciła.
Krok 7 — Odblokuj wpis w katalogu na produkcji
Plik: resources/js/types/Integrations.ts
comingSoon: false, // było true
Ta jedna flaga automatycznie robi dużo na froncie: przełącznik karty i przełączniki pod-opcji w modalu przestają być na siłę wyłączone, plakietka "New"/"Soon" zmienia się na "New", a nagłówek modala pokazuje prawdziwy przycisk Connect/Connected zamiast "Coming soon" — zobacz WorkspaceSettingsIntegrationCard.tsx i WorkspaceSettingsIntegrationDetailModal.tsx, żeby zobaczyć dokładnie, gdzie czytane jest integration.comingSoon.
Krok 8 — Testy
Odzwierciedl trzy pliki testowe Discorda, podmieniając fixture'y na Slacka:
tests/Feature/DiscordIntegrationNotifierTest.php→tests/Feature/SlackIntegrationNotifierTest.php— jeden test na rodzaj eventu, sprawdzający kształtjob->payload(textdla Slacka,embedsdla Discorda), plus test "nic nie robi bez URL-a webhooka" i test "nic nie robi dla aktualizacji bez aktora".tests/Feature/NotifyProjectIntegrationsListenerTest.php— nie trzeba nowego pliku; dodaj tam przypadki, jeśli nowa integracja ma naprawdę inne zachowanie dopasowywania kategorii (zwykle nie będzie miała — mapowanie kategorii jest per-event, nie per-integracja).tests/Feature/DiscordWebhookIntegrationEndToEndTest.php→ dodaj test end-to-end w wersji dla Slacka (wywołaj realne wywołanieIssueService/CommentService, sprawdź, czySendWebhookNotificationJobzostało wysłane do kolejki), żeby wyłapać regresję w podłączeniu — nie tylko logikę jednostkową notifiera.tests/Feature/ProjectIntegrationServiceTest.php— dodaj testy "it can save a valid slack webhook url" / "it rejects a malformed slack webhook url", odzwierciedlające istniejące testy dla Discorda, z prawdziwym URL-em w kształciehooks.slack.com/services/....tests/Feature/ProjectIntegrationControllerTest.php— istniejące testy są agnostyczne względem klucza integracji poza dwoma testami "not available yet", które przekazują id integracji, którego nigdy nie ma wAVAILABLE_INTEGRATIONS(np.'notion') — nic nie trzeba zmieniać, chyba że dodajesz ostatnią pozostałą integrację z katalogu, wtedy podmień to id fixture'a na taki, który wciąż jest zablokowany.
Uruchom php artisan test i npm test -- run przed commitem — zobacz dokładne komendy w głównym CLAUDE.md.
