Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/query-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@ export const tenantKeys = {
] as const,
};

/**
* Every surface that runs a routine (the routine panel's Run now button,
* the shell context menu's Run now item) must refresh the same two reads —
* the routines list and its run history — or one of them goes stale while
* the other doesn't.
*/
export function invalidateRoutineQueries(
queryClient: QueryClient,
tenantId: string,
): void {
void queryClient.invalidateQueries({
queryKey: tenantKeys.routines(tenantId),
});
void queryClient.invalidateQueries({
queryKey: tenantKeys.routineRunHistories(tenantId),
});
}

/**
* Map a hub GET path onto a stable query key. Unknown paths fall back to a
* path-keyed entry so callers cannot accidentally share cache entries.
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/shell/context-menu/items.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function actions(
openProfile: mock(() => undefined),
cycleTheme: mock(() => undefined),
signOut: mock(() => undefined),
onRoutineRan: mock((_tenantId: string) => undefined),
...overrides,
};
}
Expand Down Expand Up @@ -183,6 +184,54 @@ describe("shellContextMenuFor: routine", () => {
findItem(menu.entries, "open").onSelect();
expect(navigate).toHaveBeenCalledWith("/routines/rt-1");
});

test("run-now invalidates routine queries via onRoutineRan once the run starts", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ runId: "run-1" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
) as unknown as typeof fetch;
const onRoutineRan = mock((_tenantId: string) => undefined);
try {
const menu = shellContextMenuFor(target, actions({ onRoutineRan }));
findItem(menu.entries, "run-now").onSelect();
for (let i = 0; i < 10 && onRoutineRan.mock.calls.length === 0; i++) {
await Promise.resolve();
}
expect(onRoutineRan).toHaveBeenCalledWith("tenant-1");
expect(toastMock).toHaveBeenCalledWith("Nightly Digest started");
} finally {
globalThis.fetch = realFetch;
}
});

test("run-now does not invalidate routine queries when the run fails to start", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ error: { message: "boom" } }), {
status: 500,
headers: { "content-type": "application/json" },
}),
),
) as unknown as typeof fetch;
const onRoutineRan = mock((_tenantId: string) => undefined);
try {
const menu = shellContextMenuFor(target, actions({ onRoutineRan }));
findItem(menu.entries, "run-now").onSelect();
for (let i = 0; i < 10 && toastMock.mock.calls.length === 0; i++) {
await Promise.resolve();
}
expect(toastMock).toHaveBeenCalledWith("Couldn't start the routine");
expect(onRoutineRan).not.toHaveBeenCalled();
} finally {
globalThis.fetch = realFetch;
}
});
});

describe("shellContextMenuFor: insights-run", () => {
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/shell/context-menu/items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export type ShellContextMenuActions = {
readonly openProfile: (subject: ProfileSubject) => void;
readonly cycleTheme: () => void;
readonly signOut: () => void;
/** Called with the tenant id after a routine's Run now succeeds, so the
* caller can refresh the routines list and run history that a stale
* right-click menu has no other way to know changed. */
readonly onRoutineRan: (tenantId: string) => void;
};

async function copyLink(path: string, label: string): Promise<void> {
Expand Down Expand Up @@ -152,7 +156,10 @@ function routineMenu(
icon: <PlayCircle />,
onSelect: () => {
void runRoutineNow(tenantId, target.id).then(
() => toast(`${target.name} started`),
() => {
toast(`${target.name} started`);
actions.onRoutineRan(tenantId);
},
() => toast("Couldn't start the routine"),
);
},
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/shell/context-menu/shell-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
useContextMenuState,
useDocumentContextMenuTrigger,
} from "@corbits/context-menu";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";

import { useBench } from "../../bench-context";
import { useNavigate } from "../../navigation";
import { invalidateRoutineQueries } from "../../query-client";
import { useOpenProfileInCanvas } from "../canvas-availability";
import { shellContextMenuFor } from "./items";
import type { ShellContextMenuActions } from "./items";
Expand All @@ -30,6 +32,7 @@ export function ShellContextMenu({
const navigate = useNavigate();
const openProfile = useOpenProfileInCanvas();
const { cycleMode } = useTheme();
const queryClient = useQueryClient();
const { open, x, y, menu, triggerElement, show, hide } =
useContextMenuState();

Expand All @@ -39,6 +42,7 @@ export function ShellContextMenu({
openProfile,
cycleTheme: cycleMode,
signOut: onSignOut,
onRoutineRan: (tenantId) => invalidateRoutineQueries(queryClient, tenantId),
};

const resolve = useCallback(
Expand All @@ -52,7 +56,14 @@ export function ShellContextMenu({
},
// `actions` is a fresh object every render; the values it closes over
// are what actually determine the menu, so those are the real deps.
[selectedTenantId, navigate, openProfile, cycleMode, onSignOut],
[
selectedTenantId,
navigate,
openProfile,
cycleMode,
onSignOut,
queryClient,
],
);

useDocumentContextMenuTrigger({ resolve, onOpen: show });
Expand Down
9 changes: 2 additions & 7 deletions apps/web/src/shell/routine-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import {
slackTriggerOffered,
} from "../deployment-capabilities-api";
import { useGranolaPluginConnected } from "../granola-plugin-availability";
import { tenantKeys } from "../query-client";
import { invalidateRoutineQueries } from "../query-client";
import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability";
import type { RoutinePanelSubject } from "./canvas-availability";
import { CanvasPaneHeader } from "./canvas-column";
Expand Down Expand Up @@ -202,12 +202,7 @@ function RoutineEditorPanel({

const invalidateRoutines = () => {
if (tenantId === null) return;
void queryClient.invalidateQueries({
queryKey: tenantKeys.routines(tenantId),
});
void queryClient.invalidateQueries({
queryKey: tenantKeys.routineRunHistories(tenantId),
});
invalidateRoutineQueries(queryClient, tenantId);
};

const [routineId, setRoutineId] = useState<string | null>(
Expand Down
Loading