feat(rules): the rule editor asks when it applies, and the list shows its age (#3029, milestone 307 step 3, UI)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Failing after 27s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Failing after 27s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Skipped
Rule 27 — the schema and both doors shipped with no human surface, so step 3 was not shippable until this. RuleEditorSlideOver gains the trigger, the tier, the areas, and a read-only view of the rule's edges. The tier is a radio pair carrying the test itself rather than a bare toggle: can you name the trigger WITHOUT naming a system, an artifact type or a moment? If the honest answer is "whenever you are working", it is always on. It also says why conditional is not a demotion — it costs nothing when irrelevant, which is what lets a rule be as long as it needs to be. The relations block states the rule the whole milestone turns on: rules that FAIL TOGETHER are linked, never merged. RuleListPane shows the trigger and the LAST-CHANGED DATE on every row, and marks conditional only — always_on is the default and badging every row would say nothing. The date is the cheap triage the FabledCurator case wanted: a rule whose age predates the capability it duplicates is visible at a glance instead of needing a get_rule to find out. ProjectRulesTab's inline create form gains the same two fields, because a project rule bloats exactly the way a family one does — FabledCurator has 23 of them. Two type fixes the new shapes forced, both worth keeping: - toHeader() in the store: a list row is the server's rule_brief, so patching a list locally has to mirror every field it carries or the two disagree. There were two hand-built four-field literals doing that job. - ApplicableRules.rules / .project_rules are now described AS RuleHeader rather than as two more hand-written shapes — the same builder produces them, so the same type should describe them. groupByRulebookAndTopic skips a null-topic rule rather than widening TopicGroup to accept one: a rule carries topic_id XOR project_id, so a null topic in that list means something is wrong upstream, and a widened type would hide it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,24 @@
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
|
/** How a rule reaches a session (milestone 307). */
|
||||||
|
export type RuleTier = "always_on" | "conditional";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed edge between two rules. Each kind exists because its absence forced
|
||||||
|
* a workaround: merging two rules into one row, writing an override as a
|
||||||
|
* near-copy, or leaving a local addendum with nothing to say it is one.
|
||||||
|
*/
|
||||||
|
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
|
||||||
|
|
||||||
|
export interface RuleRelation {
|
||||||
|
id: number;
|
||||||
|
kind: RuleRelationKind;
|
||||||
|
/** The rule at the OTHER end. */
|
||||||
|
rule_id: number;
|
||||||
|
direction: "outgoing" | "incoming";
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Rulebook {
|
export interface Rulebook {
|
||||||
id: number;
|
id: number;
|
||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
@@ -26,35 +45,53 @@ export interface Rule {
|
|||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
|
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||||
|
when_to_apply: string;
|
||||||
|
/**
|
||||||
|
* always_on preloads into every session; conditional is reachable and
|
||||||
|
* surfaced when its trigger fires. A rule with no tier set behaves as
|
||||||
|
* always_on, which is how every rule behaved before this existed.
|
||||||
|
*/
|
||||||
|
tier: RuleTier;
|
||||||
why: string;
|
why: string;
|
||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
|
/** The note or task that caused this rule, if one was recorded. */
|
||||||
|
arose_from_id: number | null;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
|
/** Present only when the rule has them (the server omits empty keys). */
|
||||||
|
systems?: { id: number; name: string }[];
|
||||||
|
relations?: RuleRelation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
|
||||||
|
* age deliberately: a rule written before the capability it duplicates is
|
||||||
|
* otherwise indistinguishable, at a glance, from one still doing work.
|
||||||
|
*/
|
||||||
export interface RuleHeader {
|
export interface RuleHeader {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
topic_id: number | null;
|
topic_id: number | null;
|
||||||
|
tier: RuleTier;
|
||||||
|
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||||
|
updated_at: string | null;
|
||||||
|
when_to_apply?: string;
|
||||||
|
arose_from_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicableRules {
|
export interface ApplicableRules {
|
||||||
rules: {
|
// Both lists are rule_brief's output — the SAME builder, so they are
|
||||||
id: number;
|
// described the same way here rather than as two hand-written shapes that
|
||||||
title: string;
|
// drift from it and from each other (which is what the server side had).
|
||||||
statement: string;
|
rules: (RuleHeader & {
|
||||||
topic_id: number;
|
|
||||||
topic_title: string;
|
topic_title: string;
|
||||||
rulebook_id: number;
|
rulebook_id: number;
|
||||||
rulebook_title: string;
|
rulebook_title: string;
|
||||||
}[];
|
})[];
|
||||||
project_rules: {
|
project_rules: RuleHeader[];
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
statement: string;
|
|
||||||
}[];
|
|
||||||
suppressed_rules: {
|
suppressed_rules: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -133,14 +170,39 @@ export async function getRule(id: number): Promise<Rule> {
|
|||||||
return apiGet(`/api/rules/${id}`);
|
return apiGet(`/api/rules/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
|
/** The fields both write paths accept. `system_ids` REPLACES a rule's areas. */
|
||||||
|
export interface RuleWrite {
|
||||||
|
title: string;
|
||||||
|
statement: string;
|
||||||
|
when_to_apply: string;
|
||||||
|
tier: RuleTier;
|
||||||
|
why: string;
|
||||||
|
how_to_apply: string;
|
||||||
|
order_index: number;
|
||||||
|
system_ids: number[];
|
||||||
|
arose_from_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
||||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
|
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
|
||||||
return apiPatch(`/api/rules/${id}`, data);
|
return apiPatch(`/api/rules/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draw a typed edge from one rule to another. Idempotent. */
|
||||||
|
export async function relateRules(
|
||||||
|
fromRuleId: number,
|
||||||
|
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
|
||||||
|
): Promise<{ id: number }> {
|
||||||
|
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unrelateRules(relationId: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/rule-relations/${relationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRule(id: number): Promise<void> {
|
export async function deleteRule(id: number): Promise<void> {
|
||||||
return apiDelete(`/api/rules/${id}`);
|
return apiDelete(`/api/rules/${id}`);
|
||||||
}
|
}
|
||||||
@@ -161,7 +223,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
|
|||||||
|
|
||||||
export async function createProjectRule(
|
export async function createProjectRule(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
data: Partial<RuleWrite> & { statement: string },
|
||||||
): Promise<Rule> {
|
): Promise<Rule> {
|
||||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ const expandedRuleIds = ref<Set<number>>(new Set());
|
|||||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||||
|
|
||||||
const showProjectRuleForm = ref(false);
|
const showProjectRuleForm = ref(false);
|
||||||
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
const newProjectRule = ref({
|
||||||
|
title: "", statement: "", why: "", how_to_apply: "",
|
||||||
|
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||||
@@ -90,14 +93,20 @@ interface RulebookGroup {
|
|||||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||||
const byRulebook = new Map<number, RulebookGroup>();
|
const byRulebook = new Map<number, RulebookGroup>();
|
||||||
for (const r of rules) {
|
for (const r of rules) {
|
||||||
|
// A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach
|
||||||
|
// this list, so a null topic would be a server-side contradiction — skip
|
||||||
|
// it rather than widen the group's type to accommodate a case that means
|
||||||
|
// something is wrong upstream.
|
||||||
|
if (r.topic_id === null) continue;
|
||||||
|
const topicId = r.topic_id;
|
||||||
let rb = byRulebook.get(r.rulebook_id);
|
let rb = byRulebook.get(r.rulebook_id);
|
||||||
if (!rb) {
|
if (!rb) {
|
||||||
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||||
byRulebook.set(r.rulebook_id, rb);
|
byRulebook.set(r.rulebook_id, rb);
|
||||||
}
|
}
|
||||||
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
|
let topic = rb.topics.find((t) => t.topic_id === topicId);
|
||||||
if (!topic) {
|
if (!topic) {
|
||||||
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
|
topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] };
|
||||||
rb.topics.push(topic);
|
rb.topics.push(topic);
|
||||||
}
|
}
|
||||||
topic.rules.push(r);
|
topic.rules.push(r);
|
||||||
@@ -113,8 +122,13 @@ async function submitProjectRule() {
|
|||||||
title: newProjectRule.value.title.trim() || undefined,
|
title: newProjectRule.value.title.trim() || undefined,
|
||||||
why: newProjectRule.value.why.trim() || undefined,
|
why: newProjectRule.value.why.trim() || undefined,
|
||||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||||
|
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||||
|
tier: newProjectRule.value.tier,
|
||||||
});
|
});
|
||||||
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
newProjectRule.value = {
|
||||||
|
title: "", statement: "", why: "", how_to_apply: "",
|
||||||
|
when_to_apply: "", tier: "always_on",
|
||||||
|
};
|
||||||
showProjectRuleForm.value = false;
|
showProjectRuleForm.value = false;
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
@@ -219,6 +233,24 @@ watch(() => props.projectId, load);
|
|||||||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||||
rows="2"
|
rows="2"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<textarea
|
||||||
|
v-model="newProjectRule.when_to_apply"
|
||||||
|
placeholder="When to apply — the trigger, not the instruction"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<div class="tier-row">
|
||||||
|
<label>
|
||||||
|
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
||||||
|
Always on
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
|
||||||
|
Conditional
|
||||||
|
</label>
|
||||||
|
<span class="tier-hint">
|
||||||
|
Conditional if you had to name a system, an artifact or a moment to state the trigger.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newProjectRule.why"
|
v-model="newProjectRule.why"
|
||||||
placeholder="Why (optional) — the rationale"
|
placeholder="Why (optional) — the rationale"
|
||||||
@@ -345,6 +377,11 @@ watch(() => props.projectId, load);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||||
|
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.tier-row input { accent-color: var(--fs-accent); }
|
||||||
|
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||||
.chip-excluded .chip-remove { text-decoration: none; }
|
.chip-excluded .chip-remove { text-decoration: none; }
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from "vue";
|
import { computed, ref, watch, onMounted } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
|
import type { RuleTier } from "@/api/rulebooks";
|
||||||
|
|
||||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
|
const canon = useCanonicalSystemsStore();
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const statement = ref("");
|
const statement = ref("");
|
||||||
|
const whenToApply = ref("");
|
||||||
|
const tier = ref<RuleTier>("always_on");
|
||||||
|
const systemIds = ref<number[]>([]);
|
||||||
const why = ref("");
|
const why = ref("");
|
||||||
const howToApply = ref("");
|
const howToApply = ref("");
|
||||||
|
|
||||||
|
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||||
|
|
||||||
|
// The label a reader needs to judge an edge, not the stored token.
|
||||||
|
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||||||
|
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
||||||
|
overrides: { outgoing: "overrides", incoming: "is overridden by" },
|
||||||
|
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
||||||
|
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSystem(id: number) {
|
||||||
|
const at = systemIds.value.indexOf(id);
|
||||||
|
if (at >= 0) systemIds.value.splice(at, 1);
|
||||||
|
else systemIds.value.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
const isCreating = ref(props.ruleId === null);
|
const isCreating = ref(props.ruleId === null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -20,15 +45,22 @@ async function load() {
|
|||||||
if (r) {
|
if (r) {
|
||||||
title.value = r.title;
|
title.value = r.title;
|
||||||
statement.value = r.statement;
|
statement.value = r.statement;
|
||||||
|
whenToApply.value = r.when_to_apply || "";
|
||||||
|
tier.value = r.tier || "always_on";
|
||||||
|
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||||
why.value = r.why || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
title.value = "";
|
title.value = "";
|
||||||
statement.value = "";
|
statement.value = "";
|
||||||
|
whenToApply.value = "";
|
||||||
|
tier.value = "always_on";
|
||||||
|
systemIds.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
}
|
}
|
||||||
|
await canon.fetchCatalog();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -36,16 +68,21 @@ async function save() {
|
|||||||
emit("close");
|
emit("close");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const fields = {
|
||||||
|
title: title.value,
|
||||||
|
statement: statement.value,
|
||||||
|
when_to_apply: whenToApply.value,
|
||||||
|
tier: tier.value,
|
||||||
|
// Always sent, so clearing the last area actually clears it — the server
|
||||||
|
// reads a list as "these ARE the areas now".
|
||||||
|
system_ids: systemIds.value,
|
||||||
|
why: why.value,
|
||||||
|
how_to_apply: howToApply.value,
|
||||||
|
};
|
||||||
if (isCreating.value && props.topicId !== null) {
|
if (isCreating.value && props.topicId !== null) {
|
||||||
await store.createRule(props.topicId, {
|
await store.createRule(props.topicId, fields);
|
||||||
title: title.value, statement: statement.value,
|
|
||||||
why: why.value, how_to_apply: howToApply.value,
|
|
||||||
});
|
|
||||||
} else if (props.ruleId !== null) {
|
} else if (props.ruleId !== null) {
|
||||||
await store.updateRule(props.ruleId, {
|
await store.updateRule(props.ruleId, fields);
|
||||||
title: title.value, statement: statement.value,
|
|
||||||
why: why.value, how_to_apply: howToApply.value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
emit("close");
|
emit("close");
|
||||||
}
|
}
|
||||||
@@ -77,6 +114,69 @@ watch(() => props.ruleId, load);
|
|||||||
Statement <span class="required">*</span>
|
Statement <span class="required">*</span>
|
||||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
When to apply
|
||||||
|
<textarea
|
||||||
|
v-model="whenToApply"
|
||||||
|
rows="2"
|
||||||
|
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="tier">
|
||||||
|
<legend>How it reaches a session</legend>
|
||||||
|
<label class="tier-opt">
|
||||||
|
<input v-model="tier" type="radio" value="always_on" />
|
||||||
|
<span>
|
||||||
|
<strong>Always on</strong>
|
||||||
|
— loaded into every session.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="tier-opt">
|
||||||
|
<input v-model="tier" type="radio" value="conditional" />
|
||||||
|
<span>
|
||||||
|
<strong>Conditional</strong>
|
||||||
|
— arrives when its trigger fires.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="tier-test">
|
||||||
|
The test: can you name the trigger <em>without</em> naming a system, an artifact type
|
||||||
|
or a moment? If the honest answer is “whenever you are working”, it is always on.
|
||||||
|
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
|
||||||
|
it needs to be.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset v-if="canon.catalog.length" class="areas">
|
||||||
|
<legend>Areas this rule is about</legend>
|
||||||
|
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="systemIds.includes(entry.id)"
|
||||||
|
@change="toggleSystem(entry.id)"
|
||||||
|
/>
|
||||||
|
<span>{{ entry.name }}</span>
|
||||||
|
</label>
|
||||||
|
<p class="tier-test">
|
||||||
|
What lets this rule reach a project working in that area.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<section v-if="relations.length" class="relations">
|
||||||
|
<h3>Related rules</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="rel in relations" :key="rel.id" class="relation">
|
||||||
|
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
|
||||||
|
<span class="relation-target">rule #{{ rel.rule_id }}</span>
|
||||||
|
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="tier-test">
|
||||||
|
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||||
|
cited, surfaced or suppressed a clause at a time.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Why
|
Why
|
||||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||||
@@ -118,6 +218,19 @@ input, textarea {
|
|||||||
padding: 0.5rem; font: inherit;
|
padding: 0.5rem; font: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||||||
|
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||||
|
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||||
|
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||||
|
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||||
|
|
||||||
|
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||||
|
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||||
|
.relation-kind { color: var(--fs-accent); }
|
||||||
|
.relation-target { color: var(--fs-text-primary); }
|
||||||
|
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||||
.trash:hover, .close:hover { opacity: 1; }
|
.trash:hover, .close:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ const emit = defineEmits<{
|
|||||||
<header><h2>Rules</h2></header>
|
<header><h2>Rules</h2></header>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||||
<div class="title">{{ r.title }}</div>
|
<div class="title">
|
||||||
|
{{ r.title }}
|
||||||
|
<!-- Only conditional is marked: always-on is the default and
|
||||||
|
badging every row would say nothing. -->
|
||||||
|
<span v-if="r.tier === 'conditional'" class="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||||
|
</div>
|
||||||
<div class="statement">{{ r.statement }}</div>
|
<div class="statement">{{ r.statement }}</div>
|
||||||
|
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||||
|
<span v-if="r.when_to_apply" class="trigger">{{ r.when_to_apply }}</span>
|
||||||
|
<span v-if="r.updated_at" class="age" :title="`Last changed ${r.updated_at}`">{{ r.updated_at }}</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||||
@@ -35,5 +44,19 @@ li {
|
|||||||
li:hover { background: var(--fs-surface-hover); }
|
li:hover { background: var(--fs-surface-hover); }
|
||||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||||
|
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||||
|
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||||
|
.tier-chip {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-family: var(--fs-font-body);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border-radius: var(--fs-radius-pill);
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
.new-rule { cursor: pointer; }
|
.new-rule { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -98,24 +98,58 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
delete rulesByTopic.value[id];
|
delete rulesByTopic.value[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) {
|
/**
|
||||||
|
* A list row built from a full rule. The row shape is the server's
|
||||||
|
* rule_brief, so every field it carries has to be mirrored here or the two
|
||||||
|
* disagree the moment a list is patched locally instead of re-fetched.
|
||||||
|
*/
|
||||||
|
function toHeader(rule: Rule): api.RuleHeader {
|
||||||
|
return {
|
||||||
|
id: rule.id,
|
||||||
|
title: rule.title,
|
||||||
|
statement: rule.statement,
|
||||||
|
topic_id: rule.topic_id,
|
||||||
|
tier: rule.tier,
|
||||||
|
updated_at: rule.updated_at,
|
||||||
|
when_to_apply: rule.when_to_apply || undefined,
|
||||||
|
arose_from_id: rule.arose_from_id ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
|
||||||
const rule = await api.createRule(topicId, data);
|
const rule = await api.createRule(topicId, data);
|
||||||
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
||||||
rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id });
|
rulesByTopic.value[topicId].push(toHeader(rule));
|
||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRule(id: number, data: Partial<Pick<Rule, "title" | "statement" | "why" | "how_to_apply" | "order_index">>) {
|
async function updateRule(id: number, data: Partial<api.RuleWrite>) {
|
||||||
const rule = await api.updateRule(id, data);
|
const rule = await api.updateRule(id, data);
|
||||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||||
const list = rulesByTopic.value[Number(tid)];
|
const list = rulesByTopic.value[Number(tid)];
|
||||||
const idx = list.findIndex((r) => r.id === id);
|
const idx = list.findIndex((r) => r.id === id);
|
||||||
if (idx >= 0) list[idx] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id };
|
if (idx >= 0) list[idx] = toHeader(rule);
|
||||||
}
|
}
|
||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function relateRules(
|
||||||
|
fromRuleId: number,
|
||||||
|
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
|
||||||
|
) {
|
||||||
|
await api.relateRules(fromRuleId, data);
|
||||||
|
// Re-read rather than patching locally: the edge reads from BOTH ends, so
|
||||||
|
// the far rule's relations changed too and a local splice would show only
|
||||||
|
// half of what just happened.
|
||||||
|
await fetchRule(fromRuleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unrelateRules(relationId: number, refreshRuleId: number) {
|
||||||
|
await api.unrelateRules(relationId);
|
||||||
|
await fetchRule(refreshRuleId);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRule(id: number) {
|
async function deleteRule(id: number) {
|
||||||
await api.deleteRule(id);
|
await api.deleteRule(id);
|
||||||
if (currentRule.value?.id === id) currentRule.value = null;
|
if (currentRule.value?.id === id) currentRule.value = null;
|
||||||
@@ -129,6 +163,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule,
|
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user