feat(rules): project rule + topic suppressions
Lets a project mute individual rules or whole topics from rulebooks it subscribes to, without unsubscribing the rulebook. Two new association tables (migration 0060), 4 MCP tools (suppress/unsuppress × rule/topic), 4 REST endpoints, and an inline "× skip" affordance plus collapsed "Suppressed (N)" section in the project's Rules tab. get_applicable_rules now emits suppressed_rules and suppressed_topics (detail objects with rulebook/topic context, not just IDs) so the UI can render the suppressed list without a follow-up lookup. The main rules projection grew topic_id and rulebook_id columns for the per-row suppress affordance. Project deletion cascades the suppression rows via hard DELETE — they are pure associations with no soft-delete column, and restoring a deleted project should start fresh, not inherit stale mutes. Project-scoped rules (Rule.project_id) are deliberately not suppressible — delete them with delete_rule instead. Implements plan-task #187. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,9 @@ export interface ApplicableRules {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number;
|
||||
topic_title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
project_rules: {
|
||||
@@ -53,6 +55,20 @@ export interface ApplicableRules {
|
||||
title: string;
|
||||
statement: string;
|
||||
}[];
|
||||
suppressed_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
topic_id: number;
|
||||
topic_title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
suppressed_topics: {
|
||||
id: number;
|
||||
title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
truncated: boolean;
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
}
|
||||
@@ -147,3 +163,21 @@ export async function createProjectRule(
|
||||
): Promise<Rule> {
|
||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||
}
|
||||
|
||||
// ── Suppressions ───────────────────────────────────────────────────
|
||||
|
||||
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
|
||||
}
|
||||
|
||||
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
|
||||
}
|
||||
|
||||
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
|
||||
}
|
||||
|
||||
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRouter } from "vue-router";
|
||||
import {
|
||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||
listRulebooks, getRule, createProjectRule, deleteRule,
|
||||
suppressRuleForProject, unsuppressRuleForProject,
|
||||
suppressTopicForProject, unsuppressTopicForProject,
|
||||
} from "@/api/rulebooks";
|
||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||
|
||||
@@ -62,18 +64,32 @@ function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||
router.push({ path: "/rules", query });
|
||||
}
|
||||
|
||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]) {
|
||||
const grouped: Record<string, Record<string, ApplicableRules["rules"]>> = {};
|
||||
for (const r of rules) {
|
||||
if (!grouped[r.rulebook_title]) grouped[r.rulebook_title] = {};
|
||||
if (!grouped[r.rulebook_title][r.topic_title]) grouped[r.rulebook_title][r.topic_title] = [];
|
||||
grouped[r.rulebook_title][r.topic_title].push(r);
|
||||
}
|
||||
return grouped;
|
||||
interface TopicGroup {
|
||||
topic_id: number;
|
||||
topic_title: string;
|
||||
rules: ApplicableRules["rules"];
|
||||
}
|
||||
|
||||
function rulebookIdForTitle(title: string): number | undefined {
|
||||
return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id;
|
||||
interface RulebookGroup {
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
topics: TopicGroup[];
|
||||
}
|
||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||
const byRulebook = new Map<number, RulebookGroup>();
|
||||
for (const r of rules) {
|
||||
let rb = byRulebook.get(r.rulebook_id);
|
||||
if (!rb) {
|
||||
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||
byRulebook.set(r.rulebook_id, rb);
|
||||
}
|
||||
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
|
||||
if (!topic) {
|
||||
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
|
||||
rb.topics.push(topic);
|
||||
}
|
||||
topic.rules.push(r);
|
||||
}
|
||||
return Array.from(byRulebook.values());
|
||||
}
|
||||
|
||||
async function submitProjectRule() {
|
||||
@@ -96,6 +112,28 @@ async function removeProjectRule(ruleId: number) {
|
||||
await load();
|
||||
}
|
||||
|
||||
const showSuppressed = ref(false);
|
||||
|
||||
async function suppressRule(ruleId: number) {
|
||||
await suppressRuleForProject(props.projectId, ruleId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function unsuppressRule(ruleId: number) {
|
||||
await unsuppressRuleForProject(props.projectId, ruleId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function suppressTopic(topicId: number) {
|
||||
await suppressTopicForProject(props.projectId, topicId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function unsuppressTopic(topicId: number) {
|
||||
await unsuppressTopicForProject(props.projectId, topicId);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
await loadAllRulebooks();
|
||||
@@ -204,18 +242,32 @@ watch(() => props.projectId, load);
|
||||
<a @click="router.push('/rules')">Rulebooks</a>.
|
||||
</p>
|
||||
<div
|
||||
v-for="(topics, rbTitle) in groupByRulebookAndTopic(applicable.rules)"
|
||||
:key="rbTitle"
|
||||
v-for="rb in groupByRulebookAndTopic(applicable.rules)"
|
||||
:key="rb.rulebook_id"
|
||||
class="rb-group"
|
||||
>
|
||||
<h4>{{ rbTitle }}</h4>
|
||||
<div v-for="(rules, topicTitle) in topics" :key="topicTitle" class="topic-group">
|
||||
<h5>{{ topicTitle }}</h5>
|
||||
<h4>{{ rb.rulebook_title }}</h4>
|
||||
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
|
||||
<h5>
|
||||
<span>{{ topic.topic_title }}</span>
|
||||
<button
|
||||
class="skip-btn"
|
||||
:title="`Skip the entire ${topic.topic_title} topic for this project`"
|
||||
@click="suppressTopic(topic.topic_id)"
|
||||
>× skip topic</button>
|
||||
</h5>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" class="rule">
|
||||
<div class="rule-head" @click="toggleRuleExpand(r.id)">
|
||||
<span class="rule-title">{{ r.title }}</span>
|
||||
<span class="rule-statement">{{ r.statement }}</span>
|
||||
<li v-for="r in topic.rules" :key="r.id" class="rule">
|
||||
<div class="rule-head">
|
||||
<div class="rule-head-text" @click="toggleRuleExpand(r.id)">
|
||||
<span class="rule-title">{{ r.title }}</span>
|
||||
<span class="rule-statement">{{ r.statement }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="skip-btn"
|
||||
title="Skip this rule for this project"
|
||||
@click.stop="suppressRule(r.id)"
|
||||
>× skip</button>
|
||||
</div>
|
||||
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||||
<div v-if="ruleDetails[r.id].why">
|
||||
@@ -226,7 +278,7 @@ watch(() => props.projectId, load);
|
||||
</div>
|
||||
<button
|
||||
class="edit-link"
|
||||
@click="rulebookIdForTitle(String(rbTitle)) && openInRulesView(rulebookIdForTitle(String(rbTitle))!, r.id)"
|
||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||
>
|
||||
Edit in Rulebook →
|
||||
</button>
|
||||
@@ -239,6 +291,32 @@ watch(() => props.projectId, load);
|
||||
Truncated at 50 rules — there are more applicable rules.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
|
||||
class="suppressed"
|
||||
>
|
||||
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
|
||||
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
|
||||
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-if="showSuppressed" class="suppressed-body">
|
||||
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
|
||||
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
|
||||
<span class="suppressed-kind">topic</span>
|
||||
<span class="suppressed-path">{{ t.rulebook_title }} → {{ t.title }}</span>
|
||||
<button class="reenable-btn" @click="unsuppressTopic(t.id)">↻ re-enable</button>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
|
||||
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
|
||||
<span class="suppressed-kind">rule</span>
|
||||
<span class="suppressed-path">{{ r.rulebook_title }} → {{ r.topic_title }} → {{ r.title }}</span>
|
||||
<button class="reenable-btn" @click="unsuppressRule(r.id)">↻ re-enable</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -312,4 +390,49 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||
.topic-group h5 {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.rule-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
|
||||
}
|
||||
.rule-head-text { flex: 1; cursor: pointer; }
|
||||
.skip-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-muted, #888); font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topic-group h5:hover .skip-btn,
|
||||
.rule:hover .skip-btn,
|
||||
.skip-btn:focus { opacity: 1; }
|
||||
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
||||
/* Suppressed section */
|
||||
.suppressed { margin-top: 1.5rem; }
|
||||
.suppressed-toggle {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
background: none; border: none; cursor: pointer;
|
||||
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
|
||||
}
|
||||
.suppressed-toggle:hover { opacity: 1; }
|
||||
.suppressed-toggle .caret { font-size: 0.7em; }
|
||||
.suppressed-body { margin-top: 0.5rem; }
|
||||
.suppressed-list { padding-left: 0; }
|
||||
.suppressed-list li {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.25rem 0; opacity: 0.75;
|
||||
}
|
||||
.suppressed-kind {
|
||||
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||
background: var(--color-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
}
|
||||
.suppressed-path { flex: 1; }
|
||||
.reenable-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
||||
}
|
||||
.reenable-btn:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user