8000 feat(web): edit an email watch's routing rules, with a preview by adeelahmad · Pull Request #184 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
100 changes: 100 additions & 0 deletions nextjs-frontend/__tests__/mail-routes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { useState } from "react";

import { Sources } from "@/app/openapi-client";
import {
blankRule,
move,
type RuleForm,
ruleProblem,
rulesFromWatch,
rulesToApi,
SKIP,
} from "@/components/sources/route-model";
import { RoutingRules } from "@/components/sources/routing-rules";
import { TooltipProvider } from "@/components/ui/tooltip";

jest.mock("@/app/openapi-client", () => ({ Sources: { previewRoutes: jest.fn() } }));
jest.mock("next-auth/react", () => ({ useSession: () => ({ data: { accessToken: "t" } }) }));

const ok = (data: unknown) => Promise.resolve({ data, response: { ok: true, status: 200 } });

describe("routing rules model", () => {
it("round-trips saved rules, skips and deleted namespaces", () => {
const form = rulesFromWatch([
{ match: { from: ["*@acme.com", "boss@"], subject: ["invoice"] }, namespace: "finance", skip: false },
{ match: { list: ["news"] }, namespace: null, skip: true },
{ match: { to: ["support@"] }, namespace: null, skip: false }, // its namespace was deleted
]);
expect(form[0].conditions).toEqual([
{ field: "from", patterns: "*@acme.com, boss@" },
{ field: "subject", patterns: "invoice" },
]);
expect(form[1].target).toBe(SKIP);
expect(ruleProblem(form[2])).toBe("Choose a namespace, or skip.");
expect(rulesToApi(form.slice(0, 2))).toEqual([
{ match: { from: ["*@acme.com", "boss@"], subject: ["invoice"] }, namespace: "finance" },
{ match: { list: ["news"] }, skip: true },
]);
});

it("needs a pattern, and moves rules within bounds", () => {
expect(ruleProblem(blankRule("finance"))).toBe("Add a pattern to match.");
expect(move([1, 2, 3], 2, 1)).toEqual([1, 3, 2]);
expect(move([1, 2, 3], 0, -1)).toEqual([1, 2, 3]);
});
});

function Harness({ onRules }: { onRules: (r: RuleForm[]) => void }) {
const [rules, setRules] = useState<RuleForm[]>([]);
return (
<RoutingRules
rules={rules}
=> {
setRules(r);
onRules(r);
}}
namespaces={["family", "finance"]}
home="family"
source={3}
path="INBOX"
showProblems
/>
);
}

describe("routing rules editor", () => {
it("adds a rule and shows where the latest messages would go", async () => {
(Sources.previewRoutes as jest.Mock).mockReset().mockImplementation(() =>
ok([
{ path: "INBOX/7-1.eml", title: "Invoice 2231", namespace: "finance", skipped: false, rule: 1 },
{ path: "INBOX/7-2.eml", title: "Lunch?", namespace: "family", skipped: false, rule: null },
]),
);
const seen = jest.fn();
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<TooltipProvider>
<Harness />
</TooltipProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Add a rule" }));
expect(screen.getByRole("alert")).toHaveTextContent("Add a pattern to match.");
expect(screen.getByLabelText("Send to")).toHaveValue("finance"); // another namespace than the watch's
fireEvent.change(screen.getByLabelText("Patterns"), { target: { value: "*@acme.com" } });
expect(rulesToApi(seen.mock.calls.at(-1)[0])).toEqual([{ match: { from: ["*@acme.com"] }, namespace: "finance" }]);
expect(await screen.findByText("→ finance")).toBeInTheDocument();
expect(screen.getByText("rule 1")).toBeInTheDocument();
await waitFor(() =>
expect((Sources.previewRoutes as jest.Mock).mock.calls.at(-1)[0].body).toEqual({
source: 3,
path: "INBOX",
namespace: "family",
routes: [{ match: { from: ["*@acme.com"] }, namespace: "finance" }],
}),
);
});
});
64 changes: 64 additions & 0 deletions nextjs-frontend/components/sources/route-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Route, RouteView } from "@/app/openapi-client/types.gen";
import { splitPatterns } from "@/components/sources/source-model";

/** What a routing rule can look at in a message, as the backend's mail_routes.FIELDS. */
export const ROUTE_FIELDS = [
{ value: "from", label: "From", placeholder: "*@acme.com" },
{ value: "to", label: "Sent to", placeholder: "support@harbour.org" },
{ value: "subject", label: "Subject has", placeholder: "invoice, receipt" },
{ value: "list", label: "Mailing list", placeholder: "news.example.org" },
{ value: "mailbox", label: "Mailbox", placeholder: "Projects/*" },
] as const;
export type RouteField = (typeof ROUTE_FIELDS)[number]["value"];

/** "" as a target: skip the message. */
export const SKIP = "";

export type RuleCondition = { field: RouteField; patterns: string };
export type RuleForm = { key: number; conditions: RuleCondition[]; target: string | null };

let next = 1;
const key = () => next++;

export function blankRule(namespace: string | null = null): RuleForm {
return { key: key(), conditions: [{ field: "from", patterns: "" }], target: namespace };
}

/** Saved rules as the editor shows them. A rule whose namespace was deleted has no target until one is chosen. */
export function rulesFromWatch(routes: RouteView[] | undefined): RuleForm[] {
return (routes ?? []).map((r) => ({
key: key(),
conditions: Object.entries(r.match).map(([field, patterns]) => ({
field: field as RouteField,
patterns: patterns.join(", "),
})),
target: r.skip ? SKIP : (r.namespace ?? null),
}));
}

/** The editor's rules as the API takes them; conditions with no patterns are left out. */
export function rulesToApi(rules: RuleForm[]): Route[] {
return rules.map((r) => {
const match: Record<string, string[]> = {};
for (const c of r.conditions) {
const p = splitPatterns(c.patterns);
if (p.length) match[c.field] = [...(match[c.field] ?? []), ...p];
}
return r.target === SKIP ? { match, skip: true } : { match, namespace: r.target };
});
}

/** What's wrong with a rule, or null: it needs a pattern and somewhere to send the mail. */
export function ruleProblem(rule: RuleForm): string | null {
if (!rule.conditions.some((c) => splitPatterns(c.patterns).length)) return "Add a pattern to match.";
if (rule.target === null) return "Choose a namespace, or skip.";
return null;
}

export function move<T>(list: T[], from: number, to: number): T[] {
if (to < 0 || to >= list.length) return list;
const out = [...list];
const [item] = out.splice(from, 1);
out.splice(to, 0, item);
return out;
}
198 changes: 198 additions & 0 deletions nextjs-frontend/components/sources/routing-rules.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";

import { Sources } from "@/app/openapi-client";
import {
blankRule,
move,
ROUTE_FIELDS,
type RouteField,
type RuleForm,
ruleProblem,
rulesToApi,
SKIP,
} from "@/components/sources/route-model";
import { Button, IconButton } from "@/components/ui/button";
import { Input, Select } from "@/components/ui/field";
import { data, useApiClient } from "@/lib/api/browser";

function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}

/** An email watch's routing rules: the first rule that matches a new message sends it to a namespace, or skips it;
* the rest go to the watch's own namespace. Below them, where the latest messages would go. */
export function RoutingRules({
rules,
onChange,
namespaces,
home,
source,
path,
showProblems,
}: {
rules: RuleForm[];
onChange: (rules: RuleForm[]) => void;
namespaces: string[];
home: string;
source: number;
path: string;
showProblems: boolean;
}) {
const client = useApiClient();
const update = (i: number, r: Partial<RuleForm>) => onChange(rules.map((x, j) => (j === i ? { ...x, ...r } : x)));
const setCondition = (i: number, c: number, v: Partial<{ field: RouteField; patterns: string }>) =>
update(i, { conditions: rules[i].conditions.map((x, k) => (k === c ? { ...x, ...v } : x)) });

const ready = rules.every((r) => !ruleProblem(r));
const body = useDebounced(
useMemo(() => ({ source, path, namespace: home, routes: rulesToApi(rules) }), [source, path, home, rules]),
600,
);
const preview = useQuery({
queryKey: ["route-preview", body],
queryFn: () => data(Sources.previewRoutes({ client, body })),
enabled: ready && rules.length > 0 && Boolean(home),
staleTime: 60_000,
retry: false,
});

const targets = [...namespaces.map((n) => ({ value: n, label: n })), { value: SKIP, label: "Skip: don’t import" }];

return (
<div className="flex flex-col gap-2" role="group" aria-labelledby="watch-routes">
<span className="text-[13px] font-bold text-fg-strong" id="watch-routes">
Routing rules
</span>
<p className="text-[12.5px] text-fg-muted">
Send some messages to other namespaces. The first rule that matches decides; messages no rule matches go to{" "}
{home || "this watch’s namespace"}. Patterns are comma-separated and match anywhere, or use * for a pattern such
as *@acme.com. Rules apply to mail that arrives after you save.
</p>
{rules.map((r, i) => {
const problem = showProblems ? ruleProblem(r) : null;
return (
<div key={r.key} className="flex flex-col gap-2 rounded-md border border-border bg-background p-2.5">
{r.conditions.map((c, k) => (
<div key={k} className="flex items-center gap-2">
<span className="w-9 shrink-0 text-[12.5px] text-fg-muted">{k ? "and" : "If"}</span>
<Select
size="sm"
className="w-36"
aria-label="Look at"
value={c.field}
=> setCondition(i, k, { field: e.target.value as RouteField })}
options={ROUTE_FIELDS.map(({ value, label }) => ({ value, label }))}
/>
<Input
className="h-8 min-w-0 flex-1"
mono
aria-label="Patterns"
placeholder={ROUTE_FIELDS.find((x) => x.value === c.field)?.placeholder}
value={c.patterns}
=> setCondition(i, k, { patterns: e.target.value })}
/>
{r.conditions.length > 1 && (
<IconButton
label="Remove condition"
size={28}
=> update(i, { conditions: r.conditions.filter((_, j) => j !== k) })}
>
<X />
</IconButton>
)}
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<span className="w-9 shrink-0 text-[12.5px] text-fg-muted">then</span>
<Select
size="sm"
className="w-52"
aria-label="Send to"
value={r.target ?? "?"}
=> update(i, { target: e.target.value })}
options={[...(r.target === null ? [{ value: "?", label: "Choose a namespace…" }] : []), ...targets]}
/>
<Button
size="xs"
variant="ghost"
icon={<Plus />}
=> update(i, { conditions: [...r.conditions, { field: "subject", patterns: "" }] })}
>
Condition
</Button>
<span className="flex-1" />
<IconButton label="Move up" size={28} disabled={i === 0} => onChange(move(rules, i, i - 1))}>
<ArrowUp />
</IconButton>
<IconButton
label="Move down"
size={28}
disabled={i === rules.length - 1}
=> onChange(move(rules, i, i + 1))}
>
<ArrowDown />
</IconButton>
<IconButton label="Remove rule" size={28} => onChange(rules.filter((_, j) => j !== i))}>
<X />
</IconButton>
</div>
{problem && (
<p role="alert" className="text-[12.5px] text-red-dark">
{problem}
</p>
)}
</div>
);
})}
<Button
size="sm"
className="self-start"
icon={<Plus />}
=> onChange([...rules, blankRule(namespaces.find((n) => n !== home) ?? null)])}
>
Add a rule
</Button>
{rules.length > 0 && (
<div
className="flex flex-col gap-1 rounded-md border border-blue-border bg-blue-surface p-2.5"
aria-live="polite"
>
<span className="text-[12.5px] font-semibold text-fg-accent">
{!ready
? "Finish the rules to see where the latest messages would go"
: preview.isFetching
? "Checking the latest messages…"
: preview.error
? `Couldn’t check the messages: ${(preview.error as Error).message}`
: "Where the latest messages would go"}
</span>
{ready && !preview.error && (
<ul className="flex max-h-48 flex-col gap-0.5 overflow-y-auto text-[12.5px]">
{(preview.data ?? []).slice(0, 20).map((m) => (
<li key={m.path} className="flex gap-2">
<span className="min-w-0 flex-1 truncate" title={m.from?.join(", ")}>
{m.title || "(no subject)"}
</span>
<span className={m.skipped ? "text-fg-muted" : "font-semibold text-fg-strong"}>
{m.skipped ? "skipped" : `→ ${m.namespace}`}
</span>
<span className="w-12 shrink-0 text-right text-fg-muted">{m.rule ? `rule ${m.rule}` : ""}</span>
</li>
))}
{preview.data?.length === 0 && <li className="text-fg-muted">No messages there yet.</li>}
</ul>
)}
</div>
)}
</div>
);
}
4 changes: 2 additions & 2 deletions nextjs-frontend/components/sources/sources-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function watchedText(ws: Watch[]) {

type Editing = { source: Source | null; replaceToken?: boolean } | null;
type Watching = {
source: { id: number; name: string };
source: { id: number; name: string; type?: string };
path: string;
watch?: Watch | null;
} | null;
Expand Down Expand Up @@ -408,7 +408,7 @@ export function SourcesPage() {
pipelineName={pipelineName(w)}
=>
setWatching({
source: { id: current.id, name: current.name },
source: { id: current.id, name: current.name, type: current.type },
path: w.path,
watch: w,
})
Expand Down
Loading
Loading
0