fix(webui): align automation toolbar controls
This commit is contained in:
@@ -3438,10 +3438,10 @@ function AutomationsSettings({
|
|||||||
const locale = i18n.resolvedLanguage || i18n.language;
|
const locale = i18n.resolvedLanguage || i18n.language;
|
||||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const searchTokens = parseAutomationSearchQuery(query);
|
||||||
return sortAutomationJobs(jobs, sort)
|
return sortAutomationJobs(jobs, sort)
|
||||||
.filter((job) => automationMatchesFilter(job, filter))
|
.filter((job) => automationMatchesFilter(job, filter))
|
||||||
.filter((job) => !normalizedQuery || automationSearchText(job).includes(normalizedQuery));
|
.filter((job) => !searchTokens.length || automationMatchesSearch(job, searchTokens));
|
||||||
}, [filter, jobs, query, sort]);
|
}, [filter, jobs, query, sort]);
|
||||||
const activeCount = jobs.filter((job) => {
|
const activeCount = jobs.filter((job) => {
|
||||||
const key = automationStatusKey(job);
|
const key = automationStatusKey(job);
|
||||||
@@ -3478,16 +3478,16 @@ function AutomationsSettings({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<section className="rounded-[24px] border border-border/45 bg-card/80 p-3 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl sm:p-4">
|
<section className="rounded-[24px] border border-border/45 bg-card/80 p-3 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl sm:p-4">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="mx-auto flex w-full max-w-[56rem] flex-col gap-3">
|
||||||
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
||||||
<div className="inline-flex min-w-max items-center gap-1 rounded-[16px] bg-muted/50 p-1">
|
<div className="grid w-full min-w-[36rem] grid-cols-5 gap-1 rounded-[16px] bg-muted/50 p-1">
|
||||||
{summaryOptions.map((option) => (
|
{summaryOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFilterChange(option.value)}
|
onClick={() => onFilterChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-[12px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
"inline-flex h-8 min-w-0 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[12px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
||||||
filter === option.value && "bg-background text-foreground shadow-sm",
|
filter === option.value && "bg-background text-foreground shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -3506,7 +3506,7 @@ function AutomationsSettings({
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => onQueryChange(event.target.value)}
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
placeholder={tx("settings.automations.search", "Search automations")}
|
placeholder={tx("settings.automations.search", "name:quiz chat:WeChat cron:09-23")}
|
||||||
className="h-9 w-full rounded-[13px] border-border/45 bg-background/85 pl-9 text-[13px] shadow-sm"
|
className="h-9 w-full rounded-[13px] border-border/45 bg-background/85 pl-9 text-[13px] shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -4364,24 +4364,156 @@ function automationScheduleChanged(
|
|||||||
return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
|
return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
|
||||||
}
|
}
|
||||||
|
|
||||||
function automationSearchText(job: SessionAutomationJob): string {
|
type AutomationSearchField = "id" | "name" | "message" | "chat" | "cron" | "schedule" | "status";
|
||||||
const originText = job.origin
|
|
||||||
? job.origin.channel === "websocket"
|
interface AutomationSearchToken {
|
||||||
? [job.origin.session_key, job.origin.title, job.origin.preview]
|
field: AutomationSearchField | null;
|
||||||
: [job.origin.channel]
|
value: string;
|
||||||
: [];
|
}
|
||||||
|
|
||||||
|
const AUTOMATION_SEARCH_FIELDS = new Set<AutomationSearchField>([
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"message",
|
||||||
|
"chat",
|
||||||
|
"cron",
|
||||||
|
"schedule",
|
||||||
|
"status",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const AUTOMATION_CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
api: "API",
|
||||||
|
cli: "CLI",
|
||||||
|
dingtalk: "DingTalk",
|
||||||
|
discord: "Discord",
|
||||||
|
email: "Email",
|
||||||
|
feishu: "Feishu",
|
||||||
|
matrix: "Matrix",
|
||||||
|
msteams: "Microsoft Teams",
|
||||||
|
qq: "QQ",
|
||||||
|
slack: "Slack",
|
||||||
|
telegram: "Telegram",
|
||||||
|
wechat: "WeChat",
|
||||||
|
wecom: "WeCom",
|
||||||
|
weixin: "WeChat",
|
||||||
|
whatsapp: "WhatsApp",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseAutomationSearchQuery(query: string): AutomationSearchToken[] {
|
||||||
|
return (query.match(/[^\s:]+:"[^"]+"|"[^"]+"|\S+/g) ?? [])
|
||||||
|
.map((rawPart): AutomationSearchToken | null => {
|
||||||
|
const part = trimAutomationSearchValue(rawPart);
|
||||||
|
if (!part) return null;
|
||||||
|
const fieldMatch = part.match(/^([A-Za-z]+):(.*)$/);
|
||||||
|
if (!fieldMatch) return { field: null, value: part.toLowerCase() };
|
||||||
|
const field = fieldMatch[1].toLowerCase() as AutomationSearchField;
|
||||||
|
const value = trimAutomationSearchValue(fieldMatch[2]).toLowerCase();
|
||||||
|
if (!value) return null;
|
||||||
|
return AUTOMATION_SEARCH_FIELDS.has(field)
|
||||||
|
? { field, value }
|
||||||
|
: { field: null, value: part.toLowerCase() };
|
||||||
|
})
|
||||||
|
.filter((token): token is AutomationSearchToken => Boolean(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimAutomationSearchValue(value: string): string {
|
||||||
|
return value.trim().replace(/^"|"$/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationMatchesSearch(job: SessionAutomationJob, tokens: AutomationSearchToken[]): boolean {
|
||||||
|
return tokens.every((token) => automationSearchText(job, token.field).includes(token.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationSearchText(job: SessionAutomationJob, field: AutomationSearchField | null = null): string {
|
||||||
|
return automationSearchParts(job, field)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationSearchParts(
|
||||||
|
job: SessionAutomationJob,
|
||||||
|
field: AutomationSearchField | null,
|
||||||
|
): Array<string | number | null | undefined> {
|
||||||
|
const originParts = automationOriginSearchParts(job);
|
||||||
|
const scheduleParts = automationScheduleSearchParts(job);
|
||||||
|
if (field === "id") return [job.id];
|
||||||
|
if (field === "name") return [job.name, job.id];
|
||||||
|
if (field === "message") return [job.payload.message];
|
||||||
|
if (field === "chat") return originParts;
|
||||||
|
if (field === "cron" || field === "schedule") return scheduleParts;
|
||||||
|
if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
|
||||||
return [
|
return [
|
||||||
job.id,
|
job.id,
|
||||||
job.name,
|
job.name,
|
||||||
job.payload.message,
|
job.payload.message,
|
||||||
job.schedule.kind,
|
...scheduleParts,
|
||||||
job.schedule.expr,
|
automationStatusKey(job),
|
||||||
job.schedule.tz,
|
...originParts,
|
||||||
...originText,
|
];
|
||||||
]
|
}
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
function automationOriginSearchParts(job: SessionAutomationJob): Array<string | null | undefined> {
|
||||||
.toLowerCase();
|
const origin = job.origin;
|
||||||
|
if (!origin) return [];
|
||||||
|
const channel = origin.channel.trim().toLowerCase();
|
||||||
|
return [
|
||||||
|
origin.session_key,
|
||||||
|
origin.title,
|
||||||
|
origin.preview,
|
||||||
|
origin.channel,
|
||||||
|
AUTOMATION_CHANNEL_LABELS[channel],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationScheduleSearchParts(job: SessionAutomationJob): Array<string | number | null | undefined> {
|
||||||
|
const schedule = job.schedule;
|
||||||
|
const parts: Array<string | number | null | undefined> = [
|
||||||
|
schedule.kind,
|
||||||
|
schedule.expr,
|
||||||
|
schedule.tz,
|
||||||
|
schedule.every_ms,
|
||||||
|
schedule.at_ms,
|
||||||
|
];
|
||||||
|
if (schedule.kind === "cron" && schedule.expr) {
|
||||||
|
parts.push(...automationCronSearchParts(schedule.expr));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function automationCronSearchParts(expr: string): string[] {
|
||||||
|
const parts = expr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return [];
|
||||||
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||||
|
const everyDay = dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
|
||||||
|
const numericMinute = cronNumericToken(minute, 59);
|
||||||
|
const numericHour = cronNumericToken(hour, 23);
|
||||||
|
if (numericMinute === null) return [];
|
||||||
|
const paddedMinute = String(numericMinute).padStart(2, "0");
|
||||||
|
|
||||||
|
if (numericHour !== null) {
|
||||||
|
const time = `${String(numericHour).padStart(2, "0")}:${paddedMinute}`;
|
||||||
|
return [time, `:${paddedMinute}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (everyDay && hour === "*") {
|
||||||
|
return [`:${paddedMinute}`, `hourly at :${paddedMinute}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = /^(\d{1,2})-(\d{1,2})$/.exec(hour);
|
||||||
|
if (!everyDay || !range) return [];
|
||||||
|
const start = Number(range[1]);
|
||||||
|
const end = Number(range[2]);
|
||||||
|
if (start > 23 || end > 23) return [];
|
||||||
|
const paddedRange = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`;
|
||||||
|
const rawRange = `${start}-${end}`;
|
||||||
|
return [
|
||||||
|
paddedRange,
|
||||||
|
rawRange,
|
||||||
|
`:${paddedMinute}`,
|
||||||
|
`${paddedRange} at :${paddedMinute}`,
|
||||||
|
`hourly ${paddedRange} at :${paddedMinute}`,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
|
function automationMatchesFilter(job: SessionAutomationJob, filter: AutomationFilter): boolean {
|
||||||
@@ -4431,25 +4563,8 @@ function automationChannelLabel(
|
|||||||
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
|
||||||
): string {
|
): string {
|
||||||
const key = channel.trim().toLowerCase();
|
const key = channel.trim().toLowerCase();
|
||||||
const labels: Record<string, string> = {
|
return AUTOMATION_CHANNEL_LABELS[key]
|
||||||
api: "API",
|
? tx(`settings.automations.channels.${key}`, AUTOMATION_CHANNEL_LABELS[key])
|
||||||
cli: "CLI",
|
|
||||||
dingtalk: "DingTalk",
|
|
||||||
discord: "Discord",
|
|
||||||
email: "Email",
|
|
||||||
feishu: "Feishu",
|
|
||||||
matrix: "Matrix",
|
|
||||||
msteams: "Microsoft Teams",
|
|
||||||
qq: "QQ",
|
|
||||||
slack: "Slack",
|
|
||||||
telegram: "Telegram",
|
|
||||||
wechat: "WeChat",
|
|
||||||
wecom: "WeCom",
|
|
||||||
weixin: "WeChat",
|
|
||||||
whatsapp: "WhatsApp",
|
|
||||||
};
|
|
||||||
return labels[key]
|
|
||||||
? tx(`settings.automations.channels.${key}`, labels[key])
|
|
||||||
: channel;
|
: channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "Updated",
|
"updated": "Updated",
|
||||||
"name": "Name"
|
"name": "Name"
|
||||||
},
|
},
|
||||||
"search": "Search automations",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"loading": "Loading automations...",
|
"loading": "Loading automations...",
|
||||||
"noMatches": "No automations match this view.",
|
"noMatches": "No automations match this view.",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "Actualizada",
|
"updated": "Actualizada",
|
||||||
"name": "Nombre"
|
"name": "Nombre"
|
||||||
},
|
},
|
||||||
"search": "Buscar automatizaciones",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "Cola",
|
"queue": "Cola",
|
||||||
"loading": "Cargando automatizaciones...",
|
"loading": "Cargando automatizaciones...",
|
||||||
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "Mise à jour",
|
"updated": "Mise à jour",
|
||||||
"name": "Nom"
|
"name": "Nom"
|
||||||
},
|
},
|
||||||
"search": "Rechercher des automatisations",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "File",
|
"queue": "File",
|
||||||
"loading": "Chargement des automatisations...",
|
"loading": "Chargement des automatisations...",
|
||||||
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "Diperbarui",
|
"updated": "Diperbarui",
|
||||||
"name": "Nama"
|
"name": "Nama"
|
||||||
},
|
},
|
||||||
"search": "Cari otomatisasi",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "Antrean",
|
"queue": "Antrean",
|
||||||
"loading": "Memuat otomasi...",
|
"loading": "Memuat otomasi...",
|
||||||
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "更新日時",
|
"updated": "更新日時",
|
||||||
"name": "名前"
|
"name": "名前"
|
||||||
},
|
},
|
||||||
"search": "自動化を検索",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "キュー",
|
"queue": "キュー",
|
||||||
"loading": "自動タスクを読み込み中...",
|
"loading": "自動タスクを読み込み中...",
|
||||||
"noMatches": "この表示に一致する自動タスクはありません。",
|
"noMatches": "この表示に一致する自動タスクはありません。",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "업데이트",
|
"updated": "업데이트",
|
||||||
"name": "이름"
|
"name": "이름"
|
||||||
},
|
},
|
||||||
"search": "자동화 검색",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "대기열",
|
"queue": "대기열",
|
||||||
"loading": "자동화를 불러오는 중...",
|
"loading": "자동화를 불러오는 중...",
|
||||||
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "Đã cập nhật",
|
"updated": "Đã cập nhật",
|
||||||
"name": "Tên"
|
"name": "Tên"
|
||||||
},
|
},
|
||||||
"search": "Tìm tự động hóa",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "Hàng đợi",
|
"queue": "Hàng đợi",
|
||||||
"loading": "Đang tải tự động hóa...",
|
"loading": "Đang tải tự động hóa...",
|
||||||
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "更新时间",
|
"updated": "更新时间",
|
||||||
"name": "名称"
|
"name": "名称"
|
||||||
},
|
},
|
||||||
"search": "搜索自动任务",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "任务队列",
|
"queue": "任务队列",
|
||||||
"loading": "正在加载自动任务...",
|
"loading": "正在加载自动任务...",
|
||||||
"noMatches": "当前视图没有匹配的自动任务。",
|
"noMatches": "当前视图没有匹配的自动任务。",
|
||||||
|
|||||||
@@ -493,7 +493,7 @@
|
|||||||
"updated": "更新時間",
|
"updated": "更新時間",
|
||||||
"name": "名稱"
|
"name": "名稱"
|
||||||
},
|
},
|
||||||
"search": "搜尋自動任務",
|
"search": "name:quiz chat:WeChat cron:09-23",
|
||||||
"queue": "任務佇列",
|
"queue": "任務佇列",
|
||||||
"loading": "正在載入自動任務...",
|
"loading": "正在載入自動任務...",
|
||||||
"noMatches": "目前檢視沒有符合的自動任務。",
|
"noMatches": "目前檢視沒有符合的自動任務。",
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ describe("App layout", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
protected: false,
|
protected: false,
|
||||||
delete_after_run: false,
|
delete_after_run: false,
|
||||||
schedule: { kind: "every", every_ms: 3_600_000 },
|
schedule: { kind: "cron", expr: "30 9-23 * * *", tz: "Asia/Shanghai" },
|
||||||
payload: {
|
payload: {
|
||||||
message: "Send a quiz",
|
message: "Send a quiz",
|
||||||
kind: "agent_turn",
|
kind: "agent_turn",
|
||||||
@@ -452,6 +452,17 @@ describe("App layout", () => {
|
|||||||
"page",
|
"page",
|
||||||
);
|
);
|
||||||
expect(document.title).toBe("Automations · nanobot");
|
expect(document.title).toBe("Automations · nanobot");
|
||||||
|
|
||||||
|
const searchInput = within(automationsMain as HTMLElement).getByPlaceholderText(
|
||||||
|
"name:quiz chat:WeChat cron:09-23",
|
||||||
|
);
|
||||||
|
fireEvent.change(searchInput, { target: { value: "chat:WeChat" } });
|
||||||
|
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
|
||||||
|
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
fireEvent.change(searchInput, { target: { value: "cron:09-23" } });
|
||||||
|
await waitFor(() => expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument());
|
||||||
|
expect(screen.getAllByText("WeChat quiz").length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edits a past one-time automation without resubmitting its old schedule", async () => {
|
it("edits a past one-time automation without resubmitting its old schedule", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user