Let a conversation's menu show the routines that run in it
The header menu had Tasks but nothing for the schedules whose results land in that conversation. It now has Schedules, right under Tasks, opened in a modal that lists only what runs there: a bot's own chat holds the routines that run nowhere, a workspace only that bot's routines in it (`routineScopeSql`, tested against SQLite). The list loads when the modal opens, through `api/admin/bots/list-routines`, which checks the user chats in that conversation first, so the page props stay small. `grabCronJobs` gained an optional `workspace_id`: left out it still means everywhere. The routines page's row, form and edit modal moved to `src/components/general/`, and its subtitle to `src/utils/`, since both the page and the modal use them.
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A routine's name, schedule (a cron expression in UTC) and brief.
|
||||
* A routine's name, schedule (a cron expression in UTC) and brief. The
|
||||
* routines page and the conversation's routines modal both edit with it.
|
||||
*/
|
||||
export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
|
||||
const [title, setTitle] = useState(cron_job.title || "");
|
||||
+16
-5
@@ -1,10 +1,10 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import routineSubtitle from "../(utils)/routine-subtitle";
|
||||
import routineSubtitle from "@/src/utils/routine-subtitle";
|
||||
import BotAvatar from "./bot-avatar";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
@@ -12,13 +12,18 @@ type Props = {
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
/**
|
||||
* In a conversation's own modal the Chat link and the bot's and
|
||||
* workspace's names are already the ones on screen.
|
||||
*/
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One routine: its bot's avatar, its name (Paused or Done when it is),
|
||||
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Delete.
|
||||
*/
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete }: Props) {
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, compact }: Props) {
|
||||
const title = cron_job.title || `Routine ${cron_job.id}`;
|
||||
const done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs);
|
||||
const chat_url = cron_job.workspace_id
|
||||
@@ -36,9 +41,15 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete
|
||||
}
|
||||
title={title}
|
||||
badge={cron_job.enabled ? undefined : done ? "Done" : "Paused"}
|
||||
subtitle={routineSubtitle({ cron_job })}
|
||||
subtitle={routineSubtitle({
|
||||
cron_job,
|
||||
show_bot: !compact,
|
||||
show_workspace: !compact,
|
||||
})}
|
||||
>
|
||||
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
|
||||
{compact ? null : (
|
||||
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
|
||||
)}
|
||||
<SettingsRowAction
|
||||
title={cron_job.enabled ? `Pause ${title}` : `Resume ${title}`}
|
||||
label={cron_job.enabled ? "Pause" : "Resume"}
|
||||
@@ -1,6 +1,7 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import botFaceSql from "@/src/utils/bot-face-sql";
|
||||
import routineScopeSql from "@/src/utils/routine-scope/routine-scope-sql";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
@@ -8,6 +9,12 @@ type Params = {
|
||||
* Only this bot's.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
/**
|
||||
* With `bot_id`: only the routines of that conversation, here
|
||||
* (`routineScopeSql`). `null` is the bot's own chat, so it has to be
|
||||
* given rather than left out to mean "everywhere".
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Only this one.
|
||||
*/
|
||||
@@ -16,17 +23,30 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The user's schedules with their bot (its face as the user sees it) and
|
||||
* workspace names, the next to
|
||||
* run first and paused ones last.
|
||||
* workspace names, the next to run first and paused ones last. Given a
|
||||
* `bot_id` and a `workspace_id` (null for its own chat) it is one
|
||||
* conversation's: the routines that run right there.
|
||||
*/
|
||||
export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Params): Promise<CronJobWithNames[]> {
|
||||
export default async function grabCronJobs({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
cron_job_id,
|
||||
}: Params): Promise<CronJobWithNames[]> {
|
||||
const face = botFaceSql({ user_id, prefix: "bot_" });
|
||||
const where = [`c.user_id = ?`];
|
||||
const values: (number | string)[] = [user_id];
|
||||
|
||||
if (bot_id) {
|
||||
where.push(`c.bot_id = ?`);
|
||||
values.push(bot_id);
|
||||
if (workspace_id === undefined) {
|
||||
where.push(`c.bot_id = ?`);
|
||||
values.push(bot_id);
|
||||
} else {
|
||||
const scope = routineScopeSql({ bot_id, workspace_id });
|
||||
|
||||
where.push(scope.sql);
|
||||
values.push(...scope.values);
|
||||
}
|
||||
}
|
||||
|
||||
if (cron_job_id) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Load only once the modal opens, so the conversation's page props stay
|
||||
* small.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's routines: the schedules that run there, a bot's own in
|
||||
* its chat or the ones that run in the workspace. Loaded on open and
|
||||
* refreshed after every change, never part of the page props.
|
||||
*/
|
||||
export default function useBotRoutines({ bot_id, workspace_id, enabled = true }: Params) {
|
||||
const [routines, setRoutines] = useState<CronJobWithNames[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const query = new URLSearchParams({ bot_id: String(bot_id) });
|
||||
|
||||
if (workspace_id) query.set("workspace_id", String(workspace_id));
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<CronJobWithNames>>(
|
||||
`/api/admin/bots/list-routines?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success) {
|
||||
setError(res?.msg || `Couldn't load the routines.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setRoutines(res.payload || []);
|
||||
}, [bot_id, workspace_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) load().catch(() => {});
|
||||
}, [enabled, load]);
|
||||
|
||||
return { routines, setRoutines, loading, error, reload: load };
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Clock } from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, CronJobWithNames } from "@/src/types";
|
||||
import RoutineForm from "@/src/components/general/routine-form";
|
||||
import RoutineRow from "@/src/components/general/routine-row";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import { dropRoutine, replaceRoutine } from "@/src/utils/routine-list-changes";
|
||||
import useBotRoutines from "../(hooks)/use-bot-routines";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
/**
|
||||
* A row in the conversation header's menu instead of an icon beside it.
|
||||
*/
|
||||
menu?: boolean;
|
||||
on_open?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The routines that run in this conversation: the schedules whose result
|
||||
* lands here, a bot's own on its chat or the ones it runs in this
|
||||
* workspace. Open it from the header menu; the list loads then, so the
|
||||
* page's props stay small. The user pauses, resumes, edits and deletes
|
||||
* their own; the bot makes them when asked in chat.
|
||||
*/
|
||||
export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Props) {
|
||||
const [open, set_open] = useState(false);
|
||||
const [editing, set_editing] = useState<CronJobWithNames>();
|
||||
const [busy, set_busy] = useState(false);
|
||||
const [status, set_status] = useState<Status>();
|
||||
const { routines, setRoutines, loading, error } = useBotRoutines({
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id: workspace?.id ? Number(workspace.id) : null,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const name = bot.name || "This bot";
|
||||
const place = workspace?.name || "this workspace";
|
||||
const title = workspace?.id ? `${place} routines` : `${name}'s routines`;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) on_open?.();
|
||||
}, [open]);
|
||||
|
||||
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
||||
setRoutines((prev) => replaceRoutine({ routines: prev, cron_job }));
|
||||
|
||||
const toggle = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
if (busy) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, enabled: !cron_job.enabled },
|
||||
});
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
set_status({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
replace({ cron_job: res.singleRes });
|
||||
set_status({ success: true, msg: cron_job.enabled ? "Paused." : "Resumed." });
|
||||
};
|
||||
|
||||
const remove = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Delete the routine "${cron_job.title}"? A run already started carries on.`)) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await postAdminApi({ path: "routines/delete-routine", body: { cron_job_id: cron_job.id } });
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success) {
|
||||
set_status({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
setRoutines((prev) => dropRoutine({ routines: prev, cron_job_id: cron_job.id }));
|
||||
set_status({ success: true, msg: "Deleted." });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
menu ? (
|
||||
<HeaderMenuItem icon={Clock} title="Schedules" description={title} loading={loading} />
|
||||
) : (
|
||||
<Button
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
className="shrink-0 p-2! text-ink-secondary hover:text-ink hover:bg-sunken rounded-default"
|
||||
>
|
||||
<Clock size={18} aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={(next) => {
|
||||
set_open(next);
|
||||
|
||||
if (!next) {
|
||||
set_editing(undefined);
|
||||
set_status(undefined);
|
||||
}
|
||||
}}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{editing ? editing.title || "Routine" : title}
|
||||
</H2>
|
||||
{editing ? (
|
||||
<Button
|
||||
title="Back to the list"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
set_editing(undefined);
|
||||
set_status(undefined);
|
||||
}}
|
||||
className="px-2! text-[12px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{editing
|
||||
? `${name} gets this brief as a task at every run, and the result lands here.`
|
||||
: workspace?.id
|
||||
? `Work ${name} does on a schedule in ${place}. Each run is a task, and its result lands here.`
|
||||
: `Work ${name} does on its own, on a schedule. Each run is a task, and its result lands here.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-4 max-h-[55vh] w-full gap-0! items-stretch overflow-y-auto">
|
||||
{editing ? (
|
||||
<RoutineForm
|
||||
key={editing.id}
|
||||
cron_job={editing}
|
||||
onCancel={() => set_editing(undefined)}
|
||||
onSaved={({ cron_job }) => {
|
||||
replace({ cron_job });
|
||||
set_editing(undefined);
|
||||
set_status({ success: true, msg: "Saved." });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SettingsList
|
||||
is_empty={!routines.length}
|
||||
empty={loading ? "Loading routines..." : error || "No routines here yet."}
|
||||
>
|
||||
{routines.map((cron_job) => (
|
||||
<RoutineRow
|
||||
key={cron_job.id}
|
||||
cron_job={cron_job}
|
||||
compact
|
||||
busy={busy}
|
||||
onToggle={() => toggle({ cron_job }).catch(() => {})}
|
||||
onEdit={() => set_editing(cron_job)}
|
||||
onDelete={() => remove({ cron_job }).catch(() => {})}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-3 gap-1! items-stretch">
|
||||
<SettingsStatus status={status} />
|
||||
{editing ? null : (
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||
{`Ask ${name} in chat to set one up, or change one here.`}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import BotComputersModal from "./bot-computers-modal";
|
||||
import BotFilesModal from "./bot-files-modal";
|
||||
import BotTasksModal from "./bot-tasks-modal";
|
||||
import BotRoutinesModal from "./bot-routines-modal";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
import NewSessionButton from "./new-session-button";
|
||||
@@ -108,6 +109,12 @@ export default function ConversationHeaderMenu({
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotRoutinesModal
|
||||
menu
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotComputersModal
|
||||
menu
|
||||
bot={bot}
|
||||
|
||||
@@ -6,8 +6,8 @@ import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import RoutineEditModal from "../(partials)/routine-edit-modal";
|
||||
import RoutineRow from "../(partials)/routine-row";
|
||||
import RoutineEditModal from "@/src/components/general/routine-edit-modal";
|
||||
import RoutineRow from "@/src/components/general/routine-row";
|
||||
|
||||
/**
|
||||
* Every routine the user's bots have: chat with the bot, pause or resume
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import grabCronJobs from "@/src/functions/backend/db/cron-jobs/grab-cron-jobs";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* One conversation's routines: the schedules that run there, a bot's own
|
||||
* ones in its chat (`?workspace_id=` left out) or the ones that run in that
|
||||
* workspace. Only a user who chats there sees them. The modal loads them
|
||||
* when it opens, so the conversation page's props stay small.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<CronJobWithNames>> = async ({ req }) => {
|
||||
try {
|
||||
if (req.method != "GET") {
|
||||
return { success: false, msg: `Method not allowed` };
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const bot_id = Number(url.searchParams.get("bot_id")) || undefined;
|
||||
const workspace_id = Number(url.searchParams.get("workspace_id")) || undefined;
|
||||
|
||||
if (!bot_id) {
|
||||
return { success: false, msg: `Pick a bot.` };
|
||||
}
|
||||
|
||||
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: await grabCronJobs({
|
||||
user_id: user.id,
|
||||
bot_id: conversation.bot.id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import { dropRoutine, replaceRoutine } from "./routine-list-changes";
|
||||
|
||||
function routine({ id, title }: { id: number | string; title: string }): CronJobWithNames {
|
||||
return { ...({ id, title, enabled: 1, schedule: "0 9 * * *" } as CronJobWithNames) };
|
||||
}
|
||||
|
||||
const routines = [routine({ id: 1, title: "Morning" }), routine({ id: 2, title: "Evening" })];
|
||||
|
||||
describe("replaceRoutine", () => {
|
||||
test("swaps in the saved version in the same place", () => {
|
||||
const saved = routine({ id: 2, title: "Evening, paused" });
|
||||
const next = replaceRoutine({ routines, cron_job: saved });
|
||||
|
||||
expect(next.map((item) => item.id)).toEqual([1, 2]);
|
||||
expect(next[1]!.title).toBe("Evening, paused");
|
||||
});
|
||||
|
||||
test("matches a string id against a numeric one", () => {
|
||||
const next = replaceRoutine({
|
||||
routines: [routine({ id: "1", title: "Morning" }), routine({ id: 2, title: "Evening" })],
|
||||
cron_job: routine({ id: 1, title: "Morning, paused" }),
|
||||
});
|
||||
|
||||
expect(next[0]!.title).toBe("Morning, paused");
|
||||
expect(next.length).toBe(2);
|
||||
});
|
||||
|
||||
test("leaves every other routine alone", () => {
|
||||
expect(replaceRoutine({ routines, cron_job: routine({ id: 99, title: "Not here" }) })).toEqual(routines);
|
||||
});
|
||||
|
||||
test("does not change the list it was given", () => {
|
||||
const copy = [...routines];
|
||||
replaceRoutine({ routines, cron_job: routine({ id: 1, title: "Changed" }) });
|
||||
|
||||
expect(routines).toEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropRoutine", () => {
|
||||
test("removes the deleted one and keeps the rest", () => {
|
||||
expect(dropRoutine({ routines, cron_job_id: 1 }).map((item) => item.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
test("matches a string id", () => {
|
||||
expect(dropRoutine({ routines, cron_job_id: "2" }).map((item) => item.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("an id that isn't there changes nothing", () => {
|
||||
expect(dropRoutine({ routines, cron_job_id: 42 })).toEqual(routines);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Replaces one routine in a list with its saved version, keeping the order.
|
||||
* Only the row that changed moves, so a pause or an edit never reshuffles
|
||||
* the list under the pointer.
|
||||
*/
|
||||
export function replaceRoutine({
|
||||
routines,
|
||||
cron_job,
|
||||
}: {
|
||||
routines: CronJobWithNames[];
|
||||
cron_job: CronJobWithNames;
|
||||
}): CronJobWithNames[] {
|
||||
return routines.map((item) => (item.id == cron_job.id ? cron_job : item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a deleted routine from a list.
|
||||
*/
|
||||
export function dropRoutine({
|
||||
routines,
|
||||
cron_job_id,
|
||||
}: {
|
||||
routines: CronJobWithNames[];
|
||||
cron_job_id: string | number | undefined;
|
||||
}): CronJobWithNames[] {
|
||||
return routines.filter((item) => item.id != cron_job_id);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import routineScopeSql from "./routine-scope-sql";
|
||||
import type { SqlCondition } from "./types";
|
||||
|
||||
const db = new Database(":memory:");
|
||||
|
||||
beforeAll(() => {
|
||||
db.run(`CREATE TABLE cron_jobs (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER)`);
|
||||
db.run(`INSERT INTO cron_jobs VALUES
|
||||
(1, 2, NULL),
|
||||
(2, 2, 10),
|
||||
(3, 2, 11),
|
||||
(4, 3, NULL),
|
||||
(5, 3, 10)`);
|
||||
});
|
||||
|
||||
function ids({ condition }: { condition: SqlCondition }) {
|
||||
return db
|
||||
.query(`SELECT c.id FROM cron_jobs c WHERE ${condition.sql} ORDER BY c.id`)
|
||||
.all(...condition.values)
|
||||
.map((row) => (row as { id: number }).id);
|
||||
}
|
||||
|
||||
describe("routineScopeSql", () => {
|
||||
test("a bot's own chat holds only the routines that run nowhere", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 2 }) })).toEqual([1]);
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: null }) })).toEqual([4]);
|
||||
});
|
||||
|
||||
test("a workspace holds only that bot's routines there", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 10 }) })).toEqual([2]);
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 3, workspace_id: 10 }) })).toEqual([5]);
|
||||
});
|
||||
|
||||
test("another bot's routines in the same workspace are not this conversation's", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 2, workspace_id: 11 }) })).toEqual([3]);
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: 9, workspace_id: 11 }) })).toEqual([]);
|
||||
});
|
||||
|
||||
test("a string id from a query string still matches", () => {
|
||||
expect(ids({ condition: routineScopeSql({ bot_id: "2", workspace_id: "10" }) })).toEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RoutineScopeSqlParams, SqlCondition } from "./types";
|
||||
|
||||
/**
|
||||
* The routines of one conversation: that bot's schedules in that workspace,
|
||||
* or, for its own chat, the ones that run nowhere (a routine made there
|
||||
* stays there even if the bot later joins a workspace). `workspace_id` is
|
||||
* NULL in SQLite rather than 0, so the two cases can't share a comparison.
|
||||
*/
|
||||
export default function routineScopeSql({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
alias = "c",
|
||||
}: RoutineScopeSqlParams): SqlCondition {
|
||||
if (workspace_id) {
|
||||
return {
|
||||
sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id = ?)`,
|
||||
values: [Number(bot_id), Number(workspace_id)],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sql: `(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)`,
|
||||
values: [Number(bot_id)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SqlCondition } from "@/src/utils/task-access/types";
|
||||
|
||||
/**
|
||||
* The conversation a routines list belongs to: a bot's own chat (no
|
||||
* workspace), or that bot's conversation in one workspace.
|
||||
*/
|
||||
export type RoutineConversationScope = {
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined for the bot's own chat. A workspace id keeps only
|
||||
* the routines running there.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
export type RoutineScopeSqlParams = RoutineConversationScope & {
|
||||
/**
|
||||
* The table alias the condition reads.
|
||||
*/
|
||||
alias?: string;
|
||||
};
|
||||
|
||||
export type { SqlCondition };
|
||||
+17
@@ -30,4 +30,21 @@ describe("routineSubtitle", () => {
|
||||
"Scout · @daily UTC",
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves out the bot and workspace in a conversation's own modal", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: {
|
||||
bot_name: "Scout",
|
||||
workspace_name: "Launch",
|
||||
schedule: "0 9 * * *",
|
||||
enabled: 1,
|
||||
next_run_at: now + 30 * 60 * 1000,
|
||||
},
|
||||
now,
|
||||
show_bot: false,
|
||||
show_workspace: false,
|
||||
}),
|
||||
).toBe("0 9 * * * UTC · next in 30m");
|
||||
});
|
||||
});
|
||||
+15
-4
@@ -1,22 +1,33 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import timeUntil from "@/src/utils/time-until";
|
||||
import timeUntil from "./time-until";
|
||||
|
||||
type Params = {
|
||||
cron_job: CronJobWithNames;
|
||||
now?: number;
|
||||
/**
|
||||
* Shown by default; left out where the conversation already is the
|
||||
* bot's (or the workspace's).
|
||||
*/
|
||||
show_bot?: boolean;
|
||||
show_workspace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The line under a routine's name: whose it is, where it runs, its
|
||||
* schedule, then when it runs next or why it last couldn't.
|
||||
*/
|
||||
export default function routineSubtitle({ cron_job, now = Date.now() }: Params) {
|
||||
export default function routineSubtitle({
|
||||
cron_job,
|
||||
now = Date.now(),
|
||||
show_bot = true,
|
||||
show_workspace = true,
|
||||
}: Params) {
|
||||
const next =
|
||||
cron_job.enabled && cron_job.next_run_at ? `next ${timeUntil({ at: cron_job.next_run_at, now })}` : "";
|
||||
|
||||
return [
|
||||
cron_job.bot_name || "A bot",
|
||||
cron_job.workspace_name || "",
|
||||
show_bot ? cron_job.bot_name || "A bot" : "",
|
||||
show_workspace ? cron_job.workspace_name || "" : "",
|
||||
`${cron_job.schedule} UTC`,
|
||||
cron_job.last_error || next,
|
||||
]
|
||||
Reference in New Issue
Block a user