Files
HangmanLab.Frontend/src/components/Settings/PathSettings/WebhookSettingPanel.js
hzhang 952387d50f feat: dark-tech UI redesign + markdown patch cards
Redesign the frontend with a dark-tech theme: add Tailwind + PostCSS,
design tokens, and shadcn-style primitives (Button/Card/Input/Dialog/
DropdownMenu/Tabs/ScrollArea/etc.); restyle the app shell, navigation,
sidebar tree, content view, markdown rendering, editors, modals and
settings panels. Behavior/props unchanged; Font Awesome replaced with
lucide-react.

Add the patch cards feature UI: patch-queries hooks and a PatchCards
component rendered below the markdown body, with an Add Patch button
and create/edit dialog.

Fix tree expandability: folders with an index page now expand on name
click (and navigate), and the chevron+folder icon is one larger toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:28:13 +01:00

412 lines
16 KiB
JavaScript

import React, { useEffect, useState } from "react";
import {
useWebhookSetting,
useCreateWebhookSetting,
useUpdateWebhookSetting,
useWebhooks,
} from "../../../utils/queries/webhook-queries";
import {
useCreateWebhook,
useUpdateWebhook,
useDeleteWebhook,
} from "../../../utils/queries/webhook-queries";
import {useUpdatePathSetting} from "../../../utils/queries/path-setting-queries";
import { Plus, Save, Pencil, Trash2, Check } from "lucide-react";
import { Button } from "../../ui/button";
import { Input, Label } from "../../ui/input";
import { Spinner } from "../../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const CheckboxRow = ({ checked, onChange, label }) => (
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={checked}
onChange={onChange}
/>
{label}
</label>
);
const WebhookSettingPanel = ({pathSetting, onClose}) => {
const {data: setting} = useWebhookSetting(pathSetting?.webhook_setting_id || 0);
const {data: webhooks, isLoading: isWebhooksLoading} = useWebhooks();
const createWebhookSetting = useCreateWebhookSetting();
const updateWebhookSetting = useUpdateWebhookSetting();
const createWebhook = useCreateWebhook();
const updateWebhook = useUpdateWebhook();
const deleteWebhook = useDeleteWebhook();
const updatePathSetting = useUpdatePathSetting();
const [enabled, setEnabled] = useState(false);
const [isRecursive, setIsRecursive] = useState(false);
const [onEvents, setOnEvents] = useState(0);
const [selectedUrl, setSelectedUrl] = useState("");
const [headerList, setHeaderList] = useState([]);
const [additionalHeaders, setAdditionalHeaders] = useState({});
const [isOnMarkdownCreated, setIsOnMarkdownCreated] = useState(false);
const [isOnMarkdownUpdated, setIsOnMarkdownUpdated] = useState(false);
const [isOnMarkdownDeleted, setIsOnMarkdownDeleted] = useState(false);
const [isOnPathCreated, setIsOnPathCreated] = useState(false);
const [isOnPathUpdated, setIsOnPathUpdated] = useState(false);
const [isOnPathDeleted, setIsOnPathDeleted] = useState(false);
const assignFromOnEvents = (bits) => {
setIsOnMarkdownCreated(!!(bits & 1));
setIsOnMarkdownUpdated(!!(bits & 2));
setIsOnMarkdownDeleted(!!(bits & 4));
setIsOnPathCreated(!!(bits & 8));
setIsOnPathUpdated(!!(bits & 16));
setIsOnPathDeleted(!!(bits & 32));
};
const handleTriggerEventsUpdate = (eventType, isChecked) => {
setOnEvents((prev) => {
let nextVal = prev;
switch (eventType) {
case "MARKDOWN_CREATED":
nextVal = isChecked ? nextVal | 1 : nextVal & ~1;
setIsOnMarkdownCreated(isChecked);
break;
case "MARKDOWN_UPDATED":
nextVal = isChecked ? nextVal | 2 : nextVal & ~2;
setIsOnMarkdownUpdated(isChecked);
break;
case "MARKDOWN_DELETED":
nextVal = isChecked ? nextVal | 4 : nextVal & ~4;
setIsOnMarkdownDeleted(isChecked);
break;
case "PATH_CREATED":
nextVal = isChecked ? nextVal | 8 : nextVal & ~8;
setIsOnPathCreated(isChecked);
break;
case "PATH_UPDATED":
nextVal = isChecked ? nextVal | 16 : nextVal & ~16;
setIsOnPathUpdated(isChecked);
break;
case "PATH_DELETED":
nextVal = isChecked ? nextVal | 32 : nextVal & ~32;
setIsOnPathUpdated(isChecked);
break;
default:
break;
}
return nextVal;
});
};
const handleCreateWebhookSetting = () => {
createWebhookSetting.mutate({}, {
onSuccess: (data) => {
updatePathSetting.mutate({
id: pathSetting.id,
data: {
webhook_setting_id: data.id
}
});
}
});
};
useEffect(() => {
if (setting && webhooks) {
setEnabled(setting.enabled);
setIsRecursive(setting.recursive);
setOnEvents(setting.on_events);
assignFromOnEvents(setting.on_events);
try {
const headers = setting.additional_header
? JSON.parse(setting.additional_header)
: {};
setAdditionalHeaders(headers);
setHeaderList(
Object.entries(headers).map(([k, v]) => ({ key: k, value: v }))
);
} catch (err) {
setAdditionalHeaders({});
setHeaderList([]);
}
const found = webhooks.find((wh) => wh.id === setting.webhook_id);
setSelectedUrl(found ? found.hook_url : "");
} else {
setEnabled(false);
setIsRecursive(false);
setOnEvents(0);
assignFromOnEvents(0);
setAdditionalHeaders({});
setHeaderList([]);
setSelectedUrl("");
}
}, [setting, webhooks]);
const handleAddHeader = () => {
setHeaderList([...headerList, { key: "", value: "" }]);
};
const handleHeaderChange = (index, field, val) => {
const updated = [...headerList];
updated[index][field] = val;
setHeaderList(updated);
};
const handleApplyHeaders = () => {
const out = {};
headerList.forEach(({ key, value }) => {
if (key.trim()) out[key] = value;
});
setAdditionalHeaders(out);
};
const handleCreateWebhook = () => {
const newUrl = prompt("Enter the new webhook URL");
if (!newUrl) return;
createWebhook.mutate(newUrl, {
onSuccess: () => alert("Created new Webhook successfully"),
onError: () => alert("Failed to create new Webhook"),
});
};
const handleUpdateWebhook = () => {
if (!setting || !setting.webhook_id) {
alert("No webhook selected. Must pick from dropdown first.");
return;
}
const newUrl = prompt("Enter updated Webhook URL", selectedUrl);
if (!newUrl) return;
updateWebhook.mutate(
{ id: setting.webhook_id, data: { hook_url: newUrl } },
{
onSuccess: () => alert("Updated Webhook successfully"),
onError: () => alert("Failed to update Webhook"),
}
);
};
const handleDeleteWebhook = () => {
if (!setting || !setting.webhook_id) {
alert("No webhook selected to delete");
return;
}
if (!window.confirm("Are you sure?")) return;
deleteWebhook.mutate(setting.webhook_id, {
onSuccess: () => alert("Deleted Webhook successfully"),
onError: () => alert("Failed to delete Webhook"),
});
};
const handleSaveWebhookSetting = async () => {
const hook = webhooks.find((wh) => wh.hook_url === selectedUrl);
const payload = {
webhook_id: hook? hook.id : null,
recursive: isRecursive,
additional_header: JSON.stringify(additionalHeaders),
enabled,
on_events: onEvents,
};
if(!setting || !setting.id){
createWebhookSetting.mutate(payload, {
onSuccess: (res) => {
updatePathSetting.mutate({id: pathSetting.id, data: {webhook_setting_id: res.id}},{
onSuccess: () => alert("Webhook setting successfully created"),
onError: () => alert("Failed to save Webhook"),
})
},
onError: () => alert("Failed to save Webhook"),
});
} else {
updateWebhookSetting.mutate({id: setting.id, data: payload}, {
onSuccess: () => alert("Updated Webhook successfully"),
onError: () => alert("Failed to update Webhook"),
});
}
onClose();
};
return setting ? (
<div className="mt-4 space-y-5 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Webhook Setting
</h4>
<div className="space-y-2">
<Label>Select or Create a Webhook</Label>
<div className="flex items-center gap-2">
<div className="flex-1">
{isWebhooksLoading ? (
<Spinner label="Loading webhooks" />
) : (
<select
className={SELECT_CLASS}
value={selectedUrl}
onChange={(e) => setSelectedUrl(e.target.value)}
>
<option value="">(none)</option>
{webhooks.map((hook) => (
<option key={hook.id} value={hook.hook_url}>
{hook.hook_url}
</option>
))}
</select>
)}
</div>
<Button
type="button"
onClick={handleCreateWebhook}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{setting?.webhook_id && (
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUpdateWebhook}
>
<Pencil className="h-4 w-4" /> Update Webhook URL
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleDeleteWebhook}
>
<Trash2 className="h-4 w-4" /> Delete Webhook
</Button>
</div>
)}
</div>
<CheckboxRow
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
label="Enabled"
/>
<div className="space-y-2">
<Label>On Events</Label>
<div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-background/40 p-4 sm:grid-cols-2">
<div className="space-y-2">
<CheckboxRow
checked={isOnMarkdownCreated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
}
label="Markdown Created"
/>
<CheckboxRow
checked={isOnMarkdownUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
}
label="Markdown Updated"
/>
<CheckboxRow
checked={isOnMarkdownDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
}
label="Markdown Deleted"
/>
</div>
<div className="space-y-2">
<CheckboxRow
checked={isOnPathCreated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
}
label="Path Created"
/>
<CheckboxRow
checked={isOnPathUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
}
label="Path Updated"
/>
<CheckboxRow
checked={isOnPathDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
}
label="Path Deleted"
/>
</div>
</div>
</div>
<CheckboxRow
checked={isRecursive}
onChange={(e) => setIsRecursive(e.target.checked)}
label="Recursive"
/>
<div className="space-y-2">
<Label>Additional Headers</Label>
<div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
{headerList.map((h, idx) => (
<div className="flex gap-2" key={idx}>
<Input
type="text"
placeholder="key"
value={h.key}
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
/>
<Input
type="text"
placeholder="value"
value={h.value}
onChange={(e) =>
handleHeaderChange(idx, "value", e.target.value)
}
/>
</div>
))}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddHeader}
>
<Plus className="h-4 w-4" /> Header
</Button>
<Button
type="button"
size="sm"
onClick={handleApplyHeaders}
>
<Check className="h-4 w-4" /> Apply
</Button>
</div>
</div>
</div>
<Button
type="button"
onClick={handleSaveWebhookSetting}
>
<Save className="h-4 w-4" /> Save Webhook Setting
</Button>
</div>
) : (
<Button
type="button"
onClick={handleCreateWebhookSetting}
>
<Plus className="h-4 w-4" /> Create Webhook Setting
</Button>
);
}
export default WebhookSettingPanel