feat(sieve-filters): implement Sieve Filters page and integrate notification suppression logic

This commit is contained in:
Ivan
2026-04-22 20:10:55 -05:00
parent 7ea2ad3604
commit c66d290db7
6 changed files with 832 additions and 2 deletions
@@ -1119,6 +1119,9 @@ export default {
if (this.config?.do_not_disturb_enabled) {
break;
}
if (json.sieve_suppress_notifications) {
break;
}
// show notification for new messages if window is not focussed
// only for incoming messages from people (with content)
@@ -0,0 +1,447 @@
<!-- SPDX-License-Identifier: 0BSD AND MIT -->
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div class="flex-1 overflow-y-auto w-full pb-[max(1rem,env(safe-area-inset-bottom))]">
<div class="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto w-full space-y-4 min-w-0">
<div
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-200 dark:border-zinc-800 pb-4"
>
<div class="flex items-start gap-3 min-w-0">
<div
class="p-2 bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 rounded-lg shrink-0"
>
<MaterialDesignIcon icon-name="filter-variant" class="size-6" />
</div>
<div class="min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<h1 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white tracking-tight">
{{ $t("tools.sieve_filters.title") }}
</h1>
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 mt-1 max-w-2xl">
{{ $t("tools.sieve_filters.subtitle") }}
</p>
</div>
</div>
<RouterLink
to="/tools"
class="inline-flex items-center gap-2 text-sm text-violet-600 dark:text-violet-300 hover:underline shrink-0"
>
<MaterialDesignIcon icon-name="arrow-left" class="size-4" />
{{ $t("tools.sieve_filters.back_tools") }}
</RouterLink>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 items-start">
<div class="space-y-4 min-w-0 order-2 xl:order-1">
<div
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
>
<div class="flex items-center justify-between gap-2">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
{{ $t("tools.sieve_filters.rules_heading") }}
</h2>
<button
type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-violet-600 text-white hover:bg-violet-700 transition-colors"
@click="addRule"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("tools.sieve_filters.add_rule") }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ $t("tools.sieve_filters.order_hint") }}
</p>
<div
v-if="filters.length === 0"
class="text-sm text-gray-500 dark:text-gray-400 py-6 text-center"
>
{{ $t("tools.sieve_filters.empty_rules") }}
</div>
<div v-else class="space-y-3">
<div
v-for="(rule, index) in filters"
:key="rule.id"
class="rounded-lg border border-gray-200 dark:border-zinc-800 p-3 space-y-3 bg-gray-50/80 dark:bg-zinc-900/40"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<label
class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200"
>
<input
v-model="rule.enabled"
type="checkbox"
class="rounded border-gray-300"
/>
{{ $t("tools.sieve_filters.enabled") }}
</label>
<div class="flex items-center gap-1">
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-800"
:title="$t('tools.sieve_filters.move_up')"
:disabled="index === 0"
@click="moveRule(index, -1)"
>
<MaterialDesignIcon icon-name="chevron-up" class="size-5" />
</button>
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-800"
:title="$t('tools.sieve_filters.move_down')"
:disabled="index === filters.length - 1"
@click="moveRule(index, 1)"
>
<MaterialDesignIcon icon-name="chevron-down" class="size-5" />
</button>
<button
type="button"
class="p-1.5 rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-950/40"
:title="$t('tools.sieve_filters.remove_rule')"
@click="removeRule(index)"
>
<MaterialDesignIcon icon-name="delete-outline" class="size-5" />
</button>
</div>
</div>
<div>
<label
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
>{{ $t("tools.sieve_filters.terms_label") }}</label
>
<textarea
:value="termsText(rule)"
rows="3"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white font-mono"
:placeholder="$t('tools.sieve_filters.terms_placeholder')"
@input="onTermsInput(rule, $event)"
/>
</div>
<div>
<label
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
>{{ $t("tools.sieve_filters.scope_label") }}</label
>
<select
v-model="rule.scope"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
>
<option value="everyone">
{{ $t("tools.sieve_filters.scope_everyone") }}
</option>
<option value="contacts">
{{ $t("tools.sieve_filters.scope_contacts") }}
</option>
<option value="non_contacts">
{{ $t("tools.sieve_filters.scope_non_contacts") }}
</option>
</select>
</div>
<div class="space-y-2">
<div
class="text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
>
{{ $t("tools.sieve_filters.match_targets_label") }}
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<input
v-model="rule.match_peer_fields"
type="checkbox"
class="rounded border-gray-300"
@change="onMatchTargetsChange(rule)"
/>
{{ $t("tools.sieve_filters.match_peer_fields") }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<input
v-model="rule.match_message"
type="checkbox"
class="rounded border-gray-300"
@change="onMatchTargetsChange(rule)"
/>
{{ $t("tools.sieve_filters.match_message") }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ $t("tools.sieve_filters.match_targets_hint") }}
</p>
</div>
<div>
<label
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
>{{ $t("tools.sieve_filters.match_mode_label") }}</label
>
<select
v-model="rule.match_mode"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
>
<option value="substring">
{{ $t("tools.sieve_filters.match_mode_substring") }}
</option>
<option value="regex">
{{ $t("tools.sieve_filters.match_mode_regex") }}
</option>
</select>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
>{{ $t("tools.sieve_filters.action_label") }}</label
>
<select
v-model="rule.action"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
@change="onActionChange(rule)"
>
<option value="hide">
{{ $t("tools.sieve_filters.action_hide") }}
</option>
<option value="ignore">
{{ $t("tools.sieve_filters.action_ignore") }}
</option>
<option value="folder">
{{ $t("tools.sieve_filters.action_folder") }}
</option>
<option value="banish">
{{ $t("tools.sieve_filters.action_banish") }}
</option>
</select>
</div>
<div v-if="rule.action === 'folder'">
<label
class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1"
>{{ $t("tools.sieve_filters.folder_label") }}</label
>
<select
v-model.number="rule.folder_id"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-sm text-gray-900 dark:text-white"
>
<option v-for="f in folders" :key="f.id" :value="f.id">
{{ f.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 pt-2">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-gray-900 text-white dark:bg-zinc-100 dark:text-zinc-900 hover:opacity-90"
:disabled="isSaving"
@click="save"
>
<MaterialDesignIcon
v-if="!isSaving"
icon-name="content-save-outline"
class="size-4"
/>
<span v-if="isSaving">{{ $t("tools.sieve_filters.saving") }}</span>
<span v-else>{{ $t("tools.sieve_filters.save") }}</span>
</button>
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-gray-300 dark:border-zinc-600 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800"
:disabled="isSaving"
@click="reload"
>
<MaterialDesignIcon icon-name="restore" class="size-4" />
{{ $t("tools.sieve_filters.revert") }}
</button>
</div>
</div>
</div>
<div class="min-w-0 order-1 xl:order-2 space-y-2">
<h2 class="text-base font-semibold text-gray-900 dark:text-white px-1">
{{ $t("tools.sieve_filters.flow_heading") }}
</h2>
<SieveFlowNetwork :filters="filters" :folders="folders" :labels="flowLabels" />
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import SieveFlowNetwork from "./internal/SieveFlowNetwork.vue";
import ToastUtils from "../../js/ToastUtils";
function newRuleId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `r-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export default {
name: "SieveFiltersPage",
components: {
MaterialDesignIcon,
SieveFlowNetwork,
},
data() {
return {
filters: [],
folders: [],
isSaving: false,
};
},
computed: {
flowLabels() {
return {
sourceNode: this.$t("tools.sieve_filters.flow_source"),
sourceHint: this.$t("tools.sieve_filters.flow_source_hint"),
rulePrefix: this.$t("tools.sieve_filters.flow_if"),
hide: this.$t("tools.sieve_filters.flow_hide"),
ignore: this.$t("tools.sieve_filters.action_ignore"),
banish: this.$t("tools.sieve_filters.flow_banish"),
folder: this.$t("tools.sieve_filters.flow_folder"),
noRules: this.$t("tools.sieve_filters.flow_no_rules"),
graphScopeEveryone: this.$t("tools.sieve_filters.graph_scope_everyone"),
graphScopeContacts: this.$t("tools.sieve_filters.graph_scope_contacts"),
graphScopeNonContacts: this.$t("tools.sieve_filters.graph_scope_non_contacts"),
graphMatchPeer: this.$t("tools.sieve_filters.graph_match_peer"),
graphMatchMessage: this.$t("tools.sieve_filters.graph_match_message"),
graphMatchModeSubstring: this.$t("tools.sieve_filters.graph_match_mode_substring"),
graphMatchModeRegex: this.$t("tools.sieve_filters.graph_match_mode_regex"),
};
},
},
mounted() {
this.reload();
},
methods: {
termsText(rule) {
return (rule.terms || []).join("\n");
},
onTermsInput(rule, e) {
const raw = (e.target && e.target.value) || "";
rule.terms = raw
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
},
onActionChange(rule) {
if (rule.action === "folder" && this.folders.length && (rule.folder_id == null || rule.folder_id === "")) {
rule.folder_id = this.folders[0].id;
}
if (rule.action !== "folder") {
rule.folder_id = null;
}
},
onMatchTargetsChange(rule) {
if (!rule.match_peer_fields && !rule.match_message) {
rule.match_peer_fields = true;
}
},
mapRuleFromApi(r) {
let action = r.action || "ignore";
if (action === "block") {
action = "hide";
}
return {
id: r.id || newRuleId(),
enabled: r.enabled !== false,
scope: r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone",
terms: Array.isArray(r.terms) ? [...r.terms] : [],
action,
folder_id: r.folder_id ?? null,
match_peer_fields: r.match_peer_fields !== false,
match_message: !!r.match_message,
match_mode: r.match_mode === "regex" ? "regex" : "substring",
};
},
addRule() {
const base = {
id: newRuleId(),
enabled: true,
scope: "everyone",
terms: [],
action: "ignore",
folder_id: this.folders.length ? this.folders[0].id : null,
match_peer_fields: true,
match_message: false,
match_mode: "substring",
};
this.filters.push(base);
},
removeRule(index) {
this.filters.splice(index, 1);
},
moveRule(index, delta) {
const j = index + delta;
if (j < 0 || j >= this.filters.length) {
return;
}
const copy = this.filters.slice();
const t = copy[index];
copy[index] = copy[j];
copy[j] = t;
this.filters = copy;
},
normalizeForSave() {
return this.filters.map((r) => {
const scope = r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone";
const match_peer_fields = r.match_peer_fields !== false;
const match_message = !!r.match_message;
const targets_ok = match_peer_fields || match_message;
return {
id: r.id,
enabled: !!r.enabled,
scope,
terms: Array.isArray(r.terms) ? r.terms : [],
action: r.action === "block" ? "hide" : r.action,
folder_id: r.action === "folder" ? r.folder_id : null,
match_peer_fields: targets_ok ? match_peer_fields : true,
match_message: targets_ok ? match_message : false,
match_mode: r.match_mode === "regex" ? "regex" : "substring",
};
});
},
async reload() {
try {
const [fRes, foldersRes] = await Promise.all([
window.api.get("/api/v1/lxmf/sieve-filters"),
window.api.get("/api/v1/lxmf/folders"),
]);
const raw = fRes.data.filters || [];
this.filters = raw.map((r) => this.mapRuleFromApi(r));
this.folders = foldersRes.data || [];
this.onActionChangeForAll();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("tools.sieve_filters.load_failed"));
}
},
onActionChangeForAll() {
this.filters.forEach((r) => this.onActionChange(r));
},
async save() {
this.isSaving = true;
try {
const payload = { filters: this.normalizeForSave() };
const res = await window.api.put("/api/v1/lxmf/sieve-filters", payload);
this.filters = (res.data.filters || []).map((r) => this.mapRuleFromApi(r));
ToastUtils.success(this.$t("tools.sieve_filters.saved"));
} catch (e) {
const msg =
(e.response && e.response.data && e.response.data.message) ||
e.message ||
this.$t("tools.sieve_filters.save_failed");
ToastUtils.error(msg);
} finally {
this.isSaving = false;
}
},
},
};
</script>
@@ -206,6 +206,14 @@ export default {
titleKey: "tools.forwarder.title",
descriptionKey: "tools.forwarder.description",
},
{
name: "sieve-filters",
route: { name: "sieve-filters" },
icon: "filter-variant",
iconBg: "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
titleKey: "tools.sieve_filters.title",
descriptionKey: "tools.sieve_filters.description",
},
{
name: "documentation",
route: { name: "documentation" },
@@ -0,0 +1,257 @@
<!-- SPDX-License-Identifier: 0BSD AND MIT -->
<template>
<div
ref="host"
class="sieve-flow-host rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
/>
</template>
<script>
import "vis-network/styles/vis-network.css";
import { DataSet } from "vis-data";
import { Network } from "vis-network";
export default {
name: "SieveFlowNetwork",
props: {
filters: {
type: Array,
default: () => [],
},
folders: {
type: Array,
default: () => [],
},
labels: {
type: Object,
default: () => ({}),
},
},
data() {
return {
network: null,
};
},
watch: {
filters: {
deep: true,
handler() {
this.rebuild();
},
},
folders: {
deep: true,
handler() {
this.rebuild();
},
},
labels: {
deep: true,
handler() {
this.rebuild();
},
},
},
mounted() {
this.$nextTick(() => this.rebuild());
window.addEventListener("resize", this.onResize);
},
beforeUnmount() {
window.removeEventListener("resize", this.onResize);
this.destroyNetwork();
},
methods: {
onResize() {
this.network?.redraw();
this.network?.fit({ animation: false });
},
destroyNetwork() {
if (this.network) {
this.network.destroy();
this.network = null;
}
},
folderName(folderId) {
const f = this.folders.find((x) => x.id === folderId);
return f ? f.name : String(folderId);
},
rebuild() {
this.destroyNetwork();
const el = this.$refs.host;
if (!el) {
return;
}
const L = this.labels || {};
const nodes = [];
const edges = [];
const palette = {
src: { background: "#2563eb", border: "#1d4ed8", font: "#ffffff" },
rule: { background: "#f4f4f5", border: "#a1a1aa", font: "#18181b" },
ruleDark: { background: "#27272a", border: "#52525b", font: "#fafafa" },
hide: { background: "#b91c1c", border: "#991b1b", font: "#ffffff" },
ignore: { background: "#ca8a04", border: "#a16207", font: "#ffffff" },
folder: { background: "#15803d", border: "#166534", font: "#ffffff" },
banish: { background: "#4c1d95", border: "#5b21b6", font: "#ffffff" },
};
const isDark = typeof document !== "undefined" && document.documentElement.classList.contains("dark");
const ruleColors = isDark ? palette.ruleDark : palette.rule;
nodes.push({
id: "sieve-src",
label: L.sourceNode || "Peers",
title: L.sourceHint || "",
level: 0,
shape: "box",
margin: 12,
font: { color: palette.src.font, multi: true },
color: { background: palette.src.background, border: palette.src.border, highlight: palette.src },
});
const enabled = (this.filters || []).filter((r) => r && r.enabled !== false);
const outcomes = new Set();
enabled.forEach((rule, ruleIndex) => {
const rid = `sieve-rule-${rule.id || ruleIndex}`;
const sc = rule.scope === "contacts" || rule.scope === "non_contacts" ? rule.scope : "everyone";
const scopeLine =
sc === "contacts"
? L.graphScopeContacts || "Contacts"
: sc === "non_contacts"
? L.graphScopeNonContacts || "Non-contacts"
: L.graphScopeEveryone || "Everyone";
const terms = (rule.terms || []).slice(0, 4).join(", ");
const more = (rule.terms || []).length > 4 ? "…" : "";
const matchPeer = rule.match_peer_fields !== false;
const matchMsg = !!rule.match_message;
const modeLine =
rule.match_mode === "regex"
? L.graphMatchModeRegex || "regex"
: L.graphMatchModeSubstring || "substring";
const targetBits = [];
if (matchPeer) {
targetBits.push(L.graphMatchPeer || "peer");
}
if (matchMsg) {
targetBits.push(L.graphMatchMessage || "msg");
}
const targetLine = targetBits.length ? targetBits.join("+") : L.graphMatchPeer || "peer";
nodes.push({
id: rid,
label: `${scopeLine}\n${targetLine} · ${modeLine}\n${L.rulePrefix || "If"}:\n${terms || "…"}${more}`,
title: (rule.terms || []).join("\n"),
level: 1,
shape: "box",
margin: 10,
font: { color: ruleColors.font, multi: true, size: 13 },
color: {
background: ruleColors.background,
border: ruleColors.border,
highlight: ruleColors,
},
});
edges.push({
from: "sieve-src",
to: rid,
arrows: "to",
color: { color: "#94a3b8" },
});
let outId = "sieve-out-hide";
let outLabel = L.hide || "Hide";
let outColor = palette.hide;
const act = rule.action === "block" ? "hide" : rule.action;
if (act === "ignore") {
outId = "sieve-out-ignore";
outLabel = L.ignore || "Ignore";
outColor = palette.ignore;
} else if (act === "banish") {
outId = "sieve-out-banish";
outLabel = L.banish || "Banish";
outColor = palette.banish;
} else if (act === "folder" && rule.folder_id != null) {
outId = `sieve-out-folder-${rule.folder_id}`;
outLabel = `${L.folder || "Folder"}:\n${this.folderName(rule.folder_id)}`;
outColor = palette.folder;
}
outcomes.add(
JSON.stringify({
id: outId,
label: outLabel,
bg: outColor.background,
bd: outColor.border,
fg: outColor.font,
})
);
edges.push({
from: rid,
to: outId,
arrows: "to",
color: { color: "#64748b" },
});
});
outcomes.forEach((enc) => {
const o = JSON.parse(enc);
nodes.push({
id: o.id,
label: o.label,
level: 2,
shape: "box",
margin: 12,
font: { color: o.fg, multi: true },
color: {
background: o.bg,
border: o.bd,
highlight: { background: o.bg, border: o.bd },
},
});
});
if (enabled.length === 0) {
nodes.push({
id: "sieve-out-none",
label: L.noRules || "No rules",
level: 2,
shape: "box",
margin: 12,
font: { color: "#64748b" },
color: { background: "#f1f5f9", border: "#cbd5e1" },
});
edges.push({
from: "sieve-src",
to: "sieve-out-none",
arrows: "to",
color: { color: "#94a3b8" },
});
}
const data = { nodes: new DataSet(nodes), edges: new DataSet(edges) };
this.network = new Network(el, data, {
layout: {
hierarchical: {
direction: "LR",
sortMethod: "directed",
levelSeparation: 140,
nodeSpacing: 110,
},
},
physics: false,
interaction: { hover: true, zoomView: true, dragView: true },
edges: { smooth: { type: "cubicBezier", forceDirection: "horizontal" } },
});
this.network.once("stabilizationIterationsDone", () => {
this.network.fit({ animation: false });
});
},
},
};
</script>
<style scoped>
.sieve-flow-host {
width: 100%;
height: min(420px, 55vh);
min-height: 280px;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import SieveFiltersPage from "@/components/tools/SieveFiltersPage.vue";
import { createRouter, createWebHistory } from "vue-router";
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}));
describe("SieveFiltersPage.vue", () => {
const router = createRouter({
history: createWebHistory(),
routes: [{ path: "/tools", name: "tools", component: { template: "<div/>" } }],
});
beforeEach(() => {
vi.clearAllMocks();
global.api.get = vi.fn((url) => {
if (url.includes("sieve-filters")) {
return Promise.resolve({
data: {
filters: [
{
id: "r1",
enabled: true,
terms: ["spam"],
action: "ignore",
folder_id: null,
},
],
},
});
}
if (url.includes("folders")) {
return Promise.resolve({ data: [{ id: 1, name: "Inbox" }] });
}
return Promise.resolve({ data: {} });
});
global.api.put = vi.fn(() =>
Promise.resolve({
data: {
filters: [
{
id: "r1",
enabled: true,
terms: ["spam"],
action: "ignore",
folder_id: null,
},
],
},
})
);
window.api = global.api;
});
it("loads filters and folders from the API", async () => {
const wrapper = mount(SieveFiltersPage, {
global: {
plugins: [router],
mocks: { $t: (k) => k },
stubs: {
MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
SieveFlowNetwork: { template: "<div class='sieve-flow-stub'/>" },
RouterLink: { template: "<a><slot/></a>", props: ["to"] },
},
},
});
await Promise.resolve();
await wrapper.vm.$nextTick();
await Promise.resolve();
expect(global.api.get).toHaveBeenCalled();
expect(wrapper.vm.filters.length).toBe(1);
expect(wrapper.vm.filters[0].terms).toEqual(["spam"]);
expect(wrapper.vm.folders.length).toBe(1);
});
it("saves filters via PUT", async () => {
const wrapper = mount(SieveFiltersPage, {
global: {
plugins: [router],
mocks: { $t: (k) => k },
stubs: {
MaterialDesignIcon: { template: "<span/>", props: ["iconName"] },
SieveFlowNetwork: { template: "<div/>" },
RouterLink: { template: "<a><slot/></a>", props: ["to"] },
},
},
});
await Promise.resolve();
await wrapper.vm.$nextTick();
await Promise.resolve();
await wrapper.vm.save();
expect(global.api.put).toHaveBeenCalledWith(
"/api/v1/lxmf/sieve-filters",
expect.objectContaining({
filters: expect.arrayContaining([
expect.objectContaining({
match_peer_fields: true,
match_message: false,
match_mode: "substring",
}),
]),
})
);
expect(ToastUtils.success).toHaveBeenCalled();
});
});
+3 -2
View File
@@ -24,6 +24,7 @@ describe("ToolsPage.vue", () => {
{ path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
{ path: "/debug-logs", name: "debug-logs", component: { template: "div" } },
{ path: "/mesh-server", name: "mesh-server", component: { template: "div" } },
{ path: "/tools/sieve-filters", name: "sieve-filters", component: { template: "div" } },
],
});
@@ -53,7 +54,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
expect(toolRows.length).toBe(19);
expect(toolRows.length).toBe(20);
});
it("filters tools based on search query", async () => {
@@ -78,6 +79,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
expect(wrapper.vm.filteredTools.length).toBe(19);
expect(wrapper.vm.filteredTools.length).toBe(20);
});
});