7861607fb8
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>
439 lines
15 KiB
Vue
439 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, watch } from "vue";
|
||
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";
|
||
|
||
const props = defineProps<{ projectId: number }>();
|
||
const router = useRouter();
|
||
const applicable = ref<ApplicableRules | null>(null);
|
||
const allRulebooks = ref<Rulebook[]>([]);
|
||
const showPicker = ref(false);
|
||
const expandedRuleIds = ref<Set<number>>(new Set());
|
||
|
||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||
|
||
const showProjectRuleForm = ref(false);
|
||
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
||
|
||
async function load() {
|
||
applicable.value = await getProjectApplicableRules(props.projectId);
|
||
}
|
||
|
||
async function loadAllRulebooks() {
|
||
allRulebooks.value = await listRulebooks();
|
||
}
|
||
|
||
async function subscribe(rulebookId: number) {
|
||
await subscribeProject(props.projectId, rulebookId);
|
||
showPicker.value = false;
|
||
await load();
|
||
}
|
||
|
||
async function unsubscribe(rulebookId: number) {
|
||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||
await unsubscribeProject(props.projectId, rulebookId);
|
||
await load();
|
||
}
|
||
|
||
async function toggleRuleExpand(ruleId: number) {
|
||
if (expandedRuleIds.value.has(ruleId)) {
|
||
expandedRuleIds.value.delete(ruleId);
|
||
} else {
|
||
expandedRuleIds.value.add(ruleId);
|
||
if (!ruleDetails.value[ruleId]) {
|
||
const rule = await getRule(ruleId);
|
||
ruleDetails.value[ruleId] = {
|
||
why: rule.why || "",
|
||
how_to_apply: rule.how_to_apply || "",
|
||
};
|
||
}
|
||
}
|
||
// trigger reactivity on Set mutation
|
||
expandedRuleIds.value = new Set(expandedRuleIds.value);
|
||
}
|
||
|
||
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||
const query: Record<string, string> = { rb: String(rulebookId) };
|
||
if (ruleId) query.rule = String(ruleId);
|
||
router.push({ path: "/rules", query });
|
||
}
|
||
|
||
interface TopicGroup {
|
||
topic_id: number;
|
||
topic_title: string;
|
||
rules: ApplicableRules["rules"];
|
||
}
|
||
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() {
|
||
const statement = newProjectRule.value.statement.trim();
|
||
if (!statement) return;
|
||
await createProjectRule(props.projectId, {
|
||
statement,
|
||
title: newProjectRule.value.title.trim() || undefined,
|
||
why: newProjectRule.value.why.trim() || undefined,
|
||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||
});
|
||
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
||
showProjectRuleForm.value = false;
|
||
await load();
|
||
}
|
||
|
||
async function removeProjectRule(ruleId: number) {
|
||
if (!confirm("Delete this project rule? It will move to the trash.")) return;
|
||
await deleteRule(ruleId);
|
||
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();
|
||
});
|
||
|
||
watch(() => props.projectId, load);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="rules-tab" v-if="applicable">
|
||
<section class="subscribed">
|
||
<h3>Subscribed rulebooks</h3>
|
||
<div class="chips">
|
||
<span
|
||
v-for="rb in applicable.subscribed_rulebooks"
|
||
:key="rb.id"
|
||
class="chip"
|
||
>
|
||
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
||
<button class="chip-remove" @click="unsubscribe(rb.id)" aria-label="Unsubscribe">×</button>
|
||
</span>
|
||
<button v-if="!showPicker" class="add" @click="showPicker = true">+ Subscribe</button>
|
||
<select
|
||
v-else
|
||
@change="subscribe(Number(($event.target as HTMLSelectElement).value))"
|
||
>
|
||
<option value="">Choose a rulebook…</option>
|
||
<option
|
||
v-for="rb in allRulebooks.filter((rb) => !applicable!.subscribed_rulebooks.some((s) => s.id === rb.id))"
|
||
:key="rb.id"
|
||
:value="rb.id"
|
||
>
|
||
{{ rb.title }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="project-rules">
|
||
<div class="section-head">
|
||
<h3>Project rules</h3>
|
||
<button
|
||
v-if="!showProjectRuleForm"
|
||
class="add"
|
||
@click="showProjectRuleForm = true"
|
||
>
|
||
+ New project rule
|
||
</button>
|
||
</div>
|
||
<form v-if="showProjectRuleForm" class="new-rule-form" @submit.prevent="submitProjectRule">
|
||
<input
|
||
v-model="newProjectRule.title"
|
||
placeholder="Title (optional — derived from statement if blank)"
|
||
/>
|
||
<textarea
|
||
v-model="newProjectRule.statement"
|
||
required
|
||
autofocus
|
||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||
rows="2"
|
||
></textarea>
|
||
<textarea
|
||
v-model="newProjectRule.why"
|
||
placeholder="Why (optional) — the rationale"
|
||
rows="2"
|
||
></textarea>
|
||
<textarea
|
||
v-model="newProjectRule.how_to_apply"
|
||
placeholder="How to apply (optional) — when / where it kicks in"
|
||
rows="2"
|
||
></textarea>
|
||
<div class="form-buttons">
|
||
<button type="submit">Create</button>
|
||
<button type="button" @click="showProjectRuleForm = false">Cancel</button>
|
||
</div>
|
||
</form>
|
||
<ul v-if="applicable.project_rules && applicable.project_rules.length > 0" class="rule-list">
|
||
<li v-for="r in applicable.project_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>
|
||
</div>
|
||
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||
<div v-if="ruleDetails[r.id].why">
|
||
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
||
</div>
|
||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||
</div>
|
||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
<p
|
||
v-else-if="!showProjectRuleForm"
|
||
class="empty"
|
||
>
|
||
No project-only rules yet.
|
||
</p>
|
||
</section>
|
||
|
||
<section class="applicable">
|
||
<h3>Applicable rules</h3>
|
||
<p v-if="applicable.rules.length === 0" class="empty">
|
||
No rules yet — subscribe to a rulebook above, or create one at
|
||
<a @click="router.push('/rules')">Rulebooks</a>.
|
||
</p>
|
||
<div
|
||
v-for="rb in groupByRulebookAndTopic(applicable.rules)"
|
||
:key="rb.rulebook_id"
|
||
class="rb-group"
|
||
>
|
||
<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 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">
|
||
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
||
</div>
|
||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||
</div>
|
||
<button
|
||
class="edit-link"
|
||
@click="openInRulesView(r.rulebook_id, r.id)"
|
||
>
|
||
Edit in Rulebook →
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<p v-if="applicable.truncated" class="truncated">
|
||
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>
|
||
|
||
<style scoped>
|
||
.rules-tab { padding: 1rem; }
|
||
h3 {
|
||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||
margin-top: 0;
|
||
}
|
||
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||
.chip {
|
||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||
background: var(--color-primary-bg, rgba(99,102,241,0.15));
|
||
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||
}
|
||
.chip a { cursor: pointer; }
|
||
.chip-remove { background: none; border: none; cursor: pointer; opacity: 0.5; font-size: 1.1em; }
|
||
.chip-remove:hover { opacity: 1; }
|
||
.add {
|
||
background: none;
|
||
border: 1px dashed var(--color-border, #2a2a2e);
|
||
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||
color: inherit;
|
||
}
|
||
select {
|
||
background: var(--color-bg, #111113); color: inherit;
|
||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||
padding: 0.25rem 0.5rem;
|
||
}
|
||
.applicable { margin-top: 2rem; }
|
||
.rb-group { margin-bottom: 1.5rem; }
|
||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
||
.topic-group h5 {
|
||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||
margin-top: 0.75rem;
|
||
}
|
||
ul { list-style: none; padding: 0; margin: 0; }
|
||
.rule {
|
||
border-left: 2px solid var(--color-primary, #6366f1);
|
||
padding-left: 0.75rem; margin: 0.5rem 0;
|
||
}
|
||
.rule-head { cursor: pointer; }
|
||
.rule-title { font-weight: 500; }
|
||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||
.rule-detail {
|
||
margin-top: 0.5rem; padding: 0.5rem;
|
||
background: var(--color-bg, #111113); border-radius: 6px;
|
||
}
|
||
.rule-detail > div { margin-bottom: 0.5rem; }
|
||
.edit-link {
|
||
background: none; border: none; cursor: pointer;
|
||
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
|
||
}
|
||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||
.empty a { cursor: pointer; text-decoration: underline; }
|
||
.project-rules { margin-top: 1.5rem; }
|
||
.section-head { display: flex; justify-content: space-between; align-items: center; }
|
||
.new-rule-form {
|
||
display: flex; flex-direction: column; gap: 0.5rem;
|
||
padding: 0.75rem; margin: 0.5rem 0;
|
||
background: var(--color-bg, #111113);
|
||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||
}
|
||
.new-rule-form input, .new-rule-form textarea {
|
||
background: var(--color-surface, #18181b); color: inherit;
|
||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||
padding: 0.5rem; font: inherit; resize: vertical;
|
||
}
|
||
.rule-list { margin-top: 0.5rem; }
|
||
.delete-link {
|
||
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>
|