This commit is contained in:
@@ -22,6 +22,7 @@ import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
|
||||
import { SpaceMembersPanel } from './SpaceMembersPanel';
|
||||
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
|
||||
import { SpaceToolSettings } from './SpaceToolSettings';
|
||||
import { SpaceWebhookSettings } from './SpaceWebhookSettings';
|
||||
import { PythonPackagesPanel } from './PythonPackagesPanel';
|
||||
import { PieceEditor } from '../settings/PieceEditor';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
@@ -31,7 +32,7 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python';
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python' | 'webhooks';
|
||||
|
||||
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
|
||||
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
|
||||
@@ -43,6 +44,7 @@ const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
|
||||
{ id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' },
|
||||
{ id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' },
|
||||
{ id: 'python', labelKey: 'settings.nav.python', testid: 'space-settings-nav-python' },
|
||||
{ id: 'webhooks', labelKey: 'settings.nav.webhooks', testid: 'space-settings-nav-webhooks' },
|
||||
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
|
||||
];
|
||||
|
||||
@@ -88,6 +90,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'webhooks' && <SpaceWebhookSettings spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for SpaceWebhookSettings (per-space webhook notification UI).
|
||||
*
|
||||
* api + App.useAuthState are mocked so no real network and canManage=true.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
const {
|
||||
fetchMock, createMock, deleteMock, setEnabledMock, testSendMock, fetchMembersMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
createMock: vi.fn(),
|
||||
deleteMock: vi.fn(),
|
||||
setEnabledMock: vi.fn(),
|
||||
testSendMock: vi.fn(),
|
||||
fetchMembersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
fetchSpaceWebhooks: fetchMock,
|
||||
createSpaceWebhook: createMock,
|
||||
updateSpaceWebhook: vi.fn(),
|
||||
setSpaceWebhookEnabled: setEnabledMock,
|
||||
deleteSpaceWebhook: deleteMock,
|
||||
testSendSpaceWebhook: testSendMock,
|
||||
fetchSpaceMembers: fetchMembersMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../App', () => ({
|
||||
useAuthState: () => ({ mode: 'disabled' as const }),
|
||||
}));
|
||||
|
||||
import { SpaceWebhookSettings } from './SpaceWebhookSettings';
|
||||
|
||||
const ONE_WEBHOOK = [
|
||||
{
|
||||
id: 'wh-1',
|
||||
provider: 'discord' as const,
|
||||
label: 'Dev channel',
|
||||
events: ['succeeded', 'failed'] as const,
|
||||
includeDetails: true,
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
lastSuccessAt: '2026-07-01T00:00:00Z',
|
||||
lastFailureAt: null,
|
||||
failureCount: 0,
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
updatedAt: '2026-06-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const AUTO_DISABLED_WEBHOOK = [
|
||||
{
|
||||
...ONE_WEBHOOK[0],
|
||||
id: 'wh-2',
|
||||
enabled: false,
|
||||
disabledReason: '10 consecutive delivery failures',
|
||||
failureCount: 10,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
void i18n.changeLanguage('ja');
|
||||
fetchMembersMock.mockResolvedValue([]);
|
||||
fetchMock.mockResolvedValue([]);
|
||||
createMock.mockResolvedValue(ONE_WEBHOOK[0]);
|
||||
deleteMock.mockResolvedValue(undefined);
|
||||
setEnabledMock.mockResolvedValue({ ...ONE_WEBHOOK[0], enabled: true });
|
||||
testSendMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe('SpaceWebhookSettings', () => {
|
||||
it('renders an empty state when there are no webhooks', async () => {
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('space-webhook-settings')).toBeInTheDocument());
|
||||
expect(screen.getByText('まだ何も登録されていません。')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a webhook via the form', async () => {
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('webhook-label-input')).toBeInTheDocument());
|
||||
|
||||
await userEvent.type(screen.getByTestId('webhook-label-input'), 'Dev channel');
|
||||
await userEvent.type(screen.getByTestId('webhook-url-input'), 'https://discord.com/api/webhooks/1/2');
|
||||
await userEvent.click(screen.getByText('追加'));
|
||||
|
||||
await waitFor(() => expect(createMock).toHaveBeenCalledWith('s1', {
|
||||
provider: 'discord',
|
||||
label: 'Dev channel',
|
||||
url: 'https://discord.com/api/webhooks/1/2',
|
||||
events: ['succeeded', 'failed', 'waiting_human'],
|
||||
includeDetails: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('lists an existing webhook and deletes it', async () => {
|
||||
fetchMock.mockResolvedValue(ONE_WEBHOOK);
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByText('Dev channel')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByTestId('webhook-delete-wh-1'));
|
||||
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith('s1', 'wh-1'));
|
||||
});
|
||||
|
||||
it('sends a test notification via the per-webhook test button', async () => {
|
||||
fetchMock.mockResolvedValue(ONE_WEBHOOK);
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('webhook-test-wh-1')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByTestId('webhook-test-wh-1'));
|
||||
await waitFor(() => expect(testSendMock).toHaveBeenCalledWith('s1', 'wh-1'));
|
||||
await waitFor(() => expect(screen.getByText('テスト通知を送信しました。')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows the auto-disabled badge and a re-enable action for a disabled webhook', async () => {
|
||||
fetchMock.mockResolvedValue(AUTO_DISABLED_WEBHOOK);
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('webhook-disabled-badge-wh-2')).toBeInTheDocument());
|
||||
expect(screen.getByText('自動停止中')).toBeInTheDocument();
|
||||
expect(screen.getByText('10 consecutive delivery failures')).toBeInTheDocument();
|
||||
|
||||
const reenableBtn = screen.getByTestId('webhook-reenable-wh-2');
|
||||
await userEvent.click(reenableBtn);
|
||||
await waitFor(() => expect(setEnabledMock).toHaveBeenCalledWith('s1', 'wh-2', true));
|
||||
});
|
||||
|
||||
it('disables the add button until label, url, and at least one event are present', async () => {
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByText('追加')).toBeDisabled());
|
||||
});
|
||||
|
||||
it('offers Discord, Slack, and Microsoft Teams in the provider select', async () => {
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('webhook-provider-select')).toBeInTheDocument());
|
||||
const select = screen.getByTestId('webhook-provider-select') as HTMLSelectElement;
|
||||
const optionLabels = Array.from(select.options).map(o => o.value);
|
||||
expect(optionLabels).toEqual(['discord', 'slack', 'teams']);
|
||||
});
|
||||
|
||||
it('adds a Teams webhook via the form', async () => {
|
||||
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('webhook-provider-select')).toBeInTheDocument());
|
||||
|
||||
await userEvent.selectOptions(screen.getByTestId('webhook-provider-select'), 'teams');
|
||||
await userEvent.type(screen.getByTestId('webhook-label-input'), 'Ops channel');
|
||||
await userEvent.type(
|
||||
screen.getByTestId('webhook-url-input'),
|
||||
'https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz',
|
||||
);
|
||||
await userEvent.click(screen.getByText('追加'));
|
||||
|
||||
await waitFor(() => expect(createMock).toHaveBeenCalledWith('s1', {
|
||||
provider: 'teams',
|
||||
label: 'Ops channel',
|
||||
url: 'https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz',
|
||||
events: ['succeeded', 'failed', 'waiting_human'],
|
||||
includeDetails: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* SpaceWebhookSettings.tsx — ワークスペースごとの Webhook 通知設定 UI
|
||||
*
|
||||
* PR1 で Discord、PR2 で Slack、PR3 で Microsoft Teams を実装。issue #797。
|
||||
*
|
||||
* - タスク完了・失敗・回答待ちを Discord / Slack / Microsoft Teams チャンネルへ
|
||||
* 通知する Webhook をワークスペース単位で追加・削除・テスト送信できる。
|
||||
* - Webhook URL は保存後に再表示されない(write-only)。変更は再入力で上書き。
|
||||
* - 3 回連続失敗で警告、10 回連続失敗で自動停止(enabled=0 + disabledReason)。
|
||||
* 自動停止後は編集権限メンバーが再有効化できる。
|
||||
* - GET/POST/PUT/PATCH/DELETE /api/local/spaces/:id/webhooks(canManageSpace が編集)。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceWebhooks,
|
||||
fetchSpaceMembers,
|
||||
createSpaceWebhook,
|
||||
updateSpaceWebhook,
|
||||
setSpaceWebhookEnabled,
|
||||
deleteSpaceWebhook,
|
||||
testSendSpaceWebhook,
|
||||
type SpaceWebhook,
|
||||
type SpaceWebhookEvent,
|
||||
type SpaceWebhookProvider,
|
||||
} from '../../api';
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
const ALL_EVENTS: SpaceWebhookEvent[] = ['succeeded', 'failed', 'waiting_human'];
|
||||
const WARNING_FAILURE_THRESHOLD = 3;
|
||||
// All three known providers have a working adapter as of PR3 (space-webhooks-api.ts IMPLEMENTED_PROVIDERS).
|
||||
const IMPLEMENTED_PROVIDERS: SpaceWebhookProvider[] = ['discord', 'slack', 'teams'];
|
||||
const URL_PLACEHOLDER: Record<SpaceWebhookProvider, string> = {
|
||||
discord: 'https://discord.com/api/webhooks/...',
|
||||
slack: 'https://hooks.slack.com/services/...',
|
||||
teams: 'https://xxx.webhook.office.com/webhookb2/...',
|
||||
};
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export function SpaceWebhookSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [provider, setProvider] = useState<SpaceWebhookProvider>('discord');
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [events, setEvents] = useState<SpaceWebhookEvent[]>(['succeeded', 'failed', 'waiting_human']);
|
||||
const [includeDetails, setIncludeDetails] = useState(true);
|
||||
const [editingUrlFor, setEditingUrlFor] = useState<string | null>(null);
|
||||
const [editUrlValue, setEditUrlValue] = useState('');
|
||||
const [testResult, setTestResult] = useState<Record<string, 'ok' | 'error'>>({});
|
||||
|
||||
const { data: members } = useQuery({
|
||||
queryKey: ['space-members', spaceId],
|
||||
queryFn: () => fetchSpaceMembers(spaceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const ownerRow = (members ?? []).find(m => m.isOwner);
|
||||
const canManage =
|
||||
auth.mode === 'disabled' ||
|
||||
(auth.mode === 'authenticated' &&
|
||||
(auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id)));
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['space-webhooks', spaceId],
|
||||
queryFn: () => fetchSpaceWebhooks(spaceId),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['space-webhooks', spaceId] });
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createSpaceWebhook(spaceId, { provider, label: label.trim(), url: url.trim(), events, includeDetails }),
|
||||
onSuccess: () => {
|
||||
setProvider('discord');
|
||||
setLabel('');
|
||||
setUrl('');
|
||||
setEvents(['succeeded', 'failed', 'waiting_human']);
|
||||
setIncludeDetails(true);
|
||||
showToast?.(t('webhooks.added'), 'success');
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => showToast?.(t('webhooks.addFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => deleteSpaceWebhook(spaceId, id),
|
||||
onSuccess: () => { showToast?.(t('webhooks.removed'), 'success'); void invalidate(); },
|
||||
onError: (e) => showToast?.(t('webhooks.removeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const enableMut = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => setSpaceWebhookEnabled(spaceId, id, enabled),
|
||||
onSuccess: () => { void invalidate(); },
|
||||
onError: (e) => showToast?.(t('webhooks.updateFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const updateUrlMut = useMutation({
|
||||
mutationFn: ({ id, newUrl }: { id: string; newUrl: string }) => updateSpaceWebhook(spaceId, id, { url: newUrl }),
|
||||
onSuccess: () => {
|
||||
setEditingUrlFor(null);
|
||||
setEditUrlValue('');
|
||||
showToast?.(t('webhooks.urlUpdated'), 'success');
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => showToast?.(t('webhooks.updateFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const testMut = useMutation({
|
||||
mutationFn: (id: string) => testSendSpaceWebhook(spaceId, id),
|
||||
onSuccess: (result, id) => {
|
||||
setTestResult(prev => ({ ...prev, [id]: result.ok ? 'ok' : 'error' }));
|
||||
showToast?.(result.ok ? t('webhooks.testSent') : t('webhooks.testFailed', { msg: result.error ?? '' }), result.ok ? 'success' : 'error');
|
||||
},
|
||||
onError: (e, id) => {
|
||||
setTestResult(prev => ({ ...prev, [id]: 'error' }));
|
||||
showToast?.(t('webhooks.testFailed', { msg: errMsg(e) }), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const busy = createMut.isPending || deleteMut.isPending || enableMut.isPending || updateUrlMut.isPending;
|
||||
|
||||
const toggleEvent = (ev: SpaceWebhookEvent) => {
|
||||
setEvents(prev => (prev.includes(ev) ? prev.filter(e => e !== ev) : [...prev, ev]));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!label.trim() || !url.trim() || events.length === 0 || createMut.isPending) return;
|
||||
createMut.mutate();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
|
||||
</div></div>
|
||||
);
|
||||
}
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="text-[13px] text-red-600">{t('webhooks.fetchError', { msg: errMsg(error) })}</div>
|
||||
</div></div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto" data-testid="space-webhook-settings">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('webhooks.heading')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">{t('webhooks.intro')}</p>
|
||||
</div>
|
||||
|
||||
{/* 追加フォーム */}
|
||||
<section className="space-y-2 rounded-md border border-hairline bg-surface/40 p-4">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
{t('webhooks.addLabel')}
|
||||
</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={e => setProvider(e.target.value as SpaceWebhookProvider)}
|
||||
disabled={!canManage || busy}
|
||||
data-testid="webhook-provider-select"
|
||||
className="h-9 w-full rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50"
|
||||
>
|
||||
{IMPLEMENTED_PROVIDERS.map(p => (
|
||||
<option key={p} value={p}>{t(`webhooks.provider.${p}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
disabled={!canManage || busy}
|
||||
placeholder={t('webhooks.labelPlaceholder')}
|
||||
data-testid="webhook-label-input"
|
||||
className="h-9 w-full rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50"
|
||||
/>
|
||||
<input
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
disabled={!canManage || busy}
|
||||
placeholder={URL_PLACEHOLDER[provider]}
|
||||
data-testid="webhook-url-input"
|
||||
className="h-9 w-full rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3 text-[13px] text-slate-600">
|
||||
{ALL_EVENTS.map(ev => (
|
||||
<label key={ev} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events.includes(ev)}
|
||||
onChange={() => toggleEvent(ev)}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
{t(`webhooks.event.${ev}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[13px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDetails}
|
||||
onChange={e => setIncludeDetails(e.target.checked)}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
{t('webhooks.includeDetails')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!canManage || busy || !label.trim() || !url.trim() || events.length === 0}
|
||||
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createMut.isPending ? t('webhooks.adding') : t('webhooks.add')}
|
||||
</button>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">{t(`webhooks.urlHint.${provider}`)}</p>
|
||||
</section>
|
||||
|
||||
{/* 一覧 */}
|
||||
<section>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
|
||||
{t('webhooks.listHeading')}
|
||||
</h3>
|
||||
{data.length === 0 ? (
|
||||
<p className="text-[13px] text-slate-400">{t('webhooks.none')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden">
|
||||
{data.map((wh: SpaceWebhook) => {
|
||||
const isWarning = wh.enabled && wh.failureCount >= WARNING_FAILURE_THRESHOLD;
|
||||
const isEditingUrl = editingUrlFor === wh.id;
|
||||
return (
|
||||
<div key={wh.id} className="px-3 py-3 space-y-2" data-testid={`webhook-row-${wh.id}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[13px] font-medium text-slate-900">{wh.label}</span>
|
||||
<span className="text-2xs text-slate-400 uppercase">{wh.provider}</span>
|
||||
{!wh.enabled && (
|
||||
<span
|
||||
data-testid={`webhook-disabled-badge-${wh.id}`}
|
||||
className="text-2xs rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||
>
|
||||
{t('webhooks.autoDisabledBadge')}
|
||||
</span>
|
||||
)}
|
||||
{wh.enabled && isWarning && (
|
||||
<span className="text-2xs rounded px-1.5 py-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{t('webhooks.warningBadge', { count: wh.failureCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xs text-slate-500">
|
||||
{wh.events.map(ev => t(`webhooks.event.${ev}`)).join(' / ')}
|
||||
{wh.includeDetails ? '' : ` · ${t('webhooks.detailsOff')}`}
|
||||
</div>
|
||||
|
||||
<div className="text-2xs text-slate-500 space-y-0.5">
|
||||
<div>{t('webhooks.lastSuccess', { at: wh.lastSuccessAt ?? t('webhooks.never') })}</div>
|
||||
<div>{t('webhooks.lastFailure', { at: wh.lastFailureAt ?? t('webhooks.never') })}</div>
|
||||
<div>{t('webhooks.failureCount', { count: wh.failureCount })}</div>
|
||||
{wh.disabledReason && <div className="text-red-600">{wh.disabledReason}</div>}
|
||||
</div>
|
||||
|
||||
{isEditingUrl ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={editUrlValue}
|
||||
onChange={e => setEditUrlValue(e.target.value)}
|
||||
placeholder={URL_PLACEHOLDER[wh.provider]}
|
||||
data-testid={`webhook-edit-url-input-${wh.id}`}
|
||||
className="h-8 flex-1 rounded-md border border-hairline px-2 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateUrlMut.mutate({ id: wh.id, newUrl: editUrlValue.trim() })}
|
||||
disabled={!editUrlValue.trim() || updateUrlMut.isPending}
|
||||
className="text-xs text-accent hover:underline disabled:opacity-40"
|
||||
>
|
||||
{t('common:save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingUrlFor(null); setEditUrlValue(''); }}
|
||||
className="text-xs text-slate-500 hover:underline"
|
||||
>
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testMut.mutate(wh.id)}
|
||||
disabled={!canManage || testMut.isPending}
|
||||
data-testid={`webhook-test-${wh.id}`}
|
||||
className="text-xs text-accent hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{testMut.isPending ? t('webhooks.testing') : t('webhooks.test')}
|
||||
</button>
|
||||
{testResult[wh.id] === 'ok' && (
|
||||
<span className="text-2xs text-green-600">{t('webhooks.testSent')}</span>
|
||||
)}
|
||||
{testResult[wh.id] === 'error' && (
|
||||
<span className="text-2xs text-red-600">{t('webhooks.testFailedShort')}</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingUrlFor(wh.id); setEditUrlValue(''); }}
|
||||
className="text-xs text-slate-600 hover:underline"
|
||||
>
|
||||
{t('webhooks.changeUrl')}
|
||||
</button>
|
||||
)}
|
||||
{!wh.enabled && canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enableMut.mutate({ id: wh.id, enabled: true })}
|
||||
disabled={enableMut.isPending}
|
||||
data-testid={`webhook-reenable-${wh.id}`}
|
||||
className="text-xs text-accent hover:underline disabled:opacity-40"
|
||||
>
|
||||
{t('webhooks.reEnable')}
|
||||
</button>
|
||||
)}
|
||||
{wh.enabled && canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enableMut.mutate({ id: wh.id, enabled: false })}
|
||||
disabled={enableMut.isPending}
|
||||
className="text-xs text-slate-500 hover:underline disabled:opacity-40"
|
||||
>
|
||||
{t('webhooks.disable')}
|
||||
</button>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteMut.mutate(wh.id)}
|
||||
disabled={busy}
|
||||
data-testid={`webhook-delete-${wh.id}`}
|
||||
className="text-xs text-red-600 hover:text-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('webhooks.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!canManage && (
|
||||
<p className="text-[13px] text-slate-400">{t('webhooks.readonly')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user