feat(cron): delivery UI with Select dropdowns + targets fetching

- Replace free-text Input with Select for Channel + To fields
- Fetch delivery targets via HEARTBEAT_TARGETS RPC
- Populate To options based on selected Channel
- Fallback to Input when no targets available
This commit is contained in:
viettranx committed 2026-04-01 23:24:44 +07:00
1 parent 4a0d938452
commit cac3974e7c
1 file changed
+97 -22
@@ -1,17 +1,28 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import { Combobox } from "@/components/ui/combobox";
import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants";
import { toast } from "@/stores/use-toast-store";
import { useChannels } from "@/pages/channels/hooks/use-channels";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import type { CronJob, CronJobPatch } from "../hooks/use-cron";
interface DeliveryTarget {
channel: string;
chatId: string;
title?: string;
kind: string;
}
interface CronAdvancedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -34,6 +45,9 @@ function deriveState(job: CronJob) {
export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAdvancedDialogProps) {
const { t } = useTranslation("cron");
const { t: tc } = useTranslation("common");
const ws = useWs();
const { channels: availableChannels } = useChannels();
const channelNames = Object.keys(availableChannels);
const init = deriveState(job);
const [timezone, setTimezone] = useState(init.timezone);
@@ -44,6 +58,17 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
const [deleteAfterRun, setDeleteAfterRun] = useState(init.deleteAfterRun);
const [stateless, setStateless] = useState(init.stateless);
const [saving, setSaving] = useState(false);
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
const fetchTargets = useCallback(async () => {
if (!job.agentId || !ws.isConnected) return;
try {
const res = await ws.call<{ targets: DeliveryTarget[] }>(
Methods.HEARTBEAT_TARGETS, { agentId: job.agentId },
);
setTargets(res.targets ?? []);
} catch { /* ignore — fallback to Input */ }
}, [ws, job.agentId]);
// Re-sync when dialog opens
useEffect(() => {
@@ -56,6 +81,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
setWakeHeartbeat(s.wakeHeartbeat);
setDeleteAfterRun(s.deleteAfterRun);
setStateless(s.stateless);
fetchTargets();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
@@ -73,7 +99,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
await onUpdate(job.id, {
schedule: {
...job.schedule,
tz: timezone !== "UTC" ? timezone : undefined,
tz: timezone !== "UTC" ? timezone : "",
},
deliver,
deliverChannel: deliver ? channel.trim() || undefined : undefined,
@@ -131,28 +157,77 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
</div>
{deliver && (
<>
<div className="space-y-2">
<Label htmlFor="adv-channel">{t("detail.channelLabel")}</Label>
<Input
id="adv-channel"
value={channel}
onChange={(e) => setChannel(e.target.value)}
placeholder={t("detail.channelPlaceholder")}
className="text-base md:text-sm"
/>
<div className="grid grid-cols-1 gap-3">
<div className="space-y-2 min-w-0">
<Label>{t("detail.channelLabel")}</Label>
{channelNames.length > 0 ? (
<Select
value={channel || "__none__"}
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setTo(""); }}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("detail.channelPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("detail.channelPlaceholder")}</SelectItem>
{channelNames.map((ch) => (
<SelectItem key={ch} value={ch}>{ch}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={channel}
onChange={(e) => setChannel(e.target.value)}
placeholder={t("detail.channelPlaceholder")}
className="text-base md:text-sm"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="adv-to">{t("detail.toLabel")}</Label>
<Input
id="adv-to"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder={t("detail.toPlaceholder")}
className="text-base md:text-sm"
/>
<div className="space-y-2 min-w-0">
<Label>{t("detail.toLabel")}</Label>
{(() => {
if (!channel) {
return (
<Input
placeholder={t("detail.channelPlaceholder")}
disabled
className="text-base md:text-sm"
/>
);
}
const filtered = targets.filter((tgt) => tgt.channel === channel);
if (filtered.length > 0) {
return (
<Select
value={to || "__none__"}
onValueChange={(v) => setTo(v === "__none__" ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("detail.toPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t("detail.toPlaceholder")}</SelectItem>
{filtered.map((tgt) => (
<SelectItem key={tgt.chatId} value={tgt.chatId} title={tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}>
<span className="truncate">{tgt.title ? `${tgt.title} (${tgt.chatId})` : tgt.chatId}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder={t("detail.toPlaceholder")}
className="text-base md:text-sm"
/>
);
})()}
</div>
</>
</div>
)}
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">