feat(rulebook): Project Rules tab — applicable rules + subscription chips
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import {
|
||||||
|
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||||
|
listRulebooks, getRule,
|
||||||
|
} 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 }>>({});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rulebookIdForTitle(title: string): number | undefined {
|
||||||
|
return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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="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="(topics, rbTitle) in groupByRulebookAndTopic(applicable.rules)"
|
||||||
|
:key="rbTitle"
|
||||||
|
class="rb-group"
|
||||||
|
>
|
||||||
|
<h4>{{ rbTitle }}</h4>
|
||||||
|
<div v-for="(rules, topicTitle) in topics" :key="topicTitle" class="topic-group">
|
||||||
|
<h5>{{ topicTitle }}</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>
|
||||||
|
</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="rulebookIdForTitle(String(rbTitle)) && openInRulesView(rulebookIdForTitle(String(rbTitle))!, 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>
|
||||||
|
</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; }
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
|||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
import { relativeTime } from "@/composables/useRelativeTime";
|
||||||
import ShareDialog from "@/components/ShareDialog.vue";
|
import ShareDialog from "@/components/ShareDialog.vue";
|
||||||
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -67,7 +68,7 @@ const loading = ref(false);
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
const activeTab = ref<"tasks" | "notes">("tasks");
|
const activeTab = ref<"tasks" | "notes" | "rules">("tasks");
|
||||||
|
|
||||||
const tasks = ref<NoteItem[]>([]);
|
const tasks = ref<NoteItem[]>([]);
|
||||||
const notes = ref<NoteItem[]>([]);
|
const notes = ref<NoteItem[]>([]);
|
||||||
@@ -436,6 +437,9 @@ async function confirmDelete() {
|
|||||||
Notes
|
Notes
|
||||||
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
|
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
||||||
|
Rules
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tasks tab — milestone-grouped kanban -->
|
<!-- Tasks tab — milestone-grouped kanban -->
|
||||||
@@ -616,6 +620,9 @@ async function confirmDelete() {
|
|||||||
<p v-if="!notes.length" class="empty-msg">No notes in this project.</p>
|
<p v-if="!notes.length" class="empty-msg">No notes in this project.</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules tab -->
|
||||||
|
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user