feat(rules): move a rule between global and project scope, keeping its id, history, areas and edges (#4063)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 33s
A rule's home is its reach: a rulebook topic makes it global, a project makes it that project's. There was no way to change one, so a project rule decided to be global could only be recreated and the original trashed — losing the id every record cites, its edit history, its area tags and its relations. - services.rulebooks.move_rule(rule_id, user_id, topic_id= | project_id=): exactly one destination (the model's CHECK), owned by the caller, not the rule's current home. A topic already holding a live rule with the same title is refused with a message naming that rule, instead of uq_rule_per_topic failing the commit. Someone else's rule reads as not found. - Deliberately NOT done, and said in the docstring: no version (a version is what a rule said, milestone 323 decision 4), no duplicate gate (nothing new enters the corpus), no re-embed (retrieval reads the home at query time). - Both doors: MCP move_rule, REST POST /api/rules/<id>/move (rule 33). - UI: RuleHomePicker, one component in the rule editor (a global rule) and a project's rules tab (a project rule), so the two cannot drift on what a destination is. - using-scribe names move_rule under "Where a new rule goes". Plugin 2026.09.15.1626. Milestone 414 step 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -198,6 +198,14 @@ export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<
|
||||
return apiPatch(`/api/rules/${id}`, data);
|
||||
}
|
||||
|
||||
/** Give a rule a new home: a topic makes it global, a project makes it that
|
||||
* project's. Keeps its id, history, areas and relations (milestone 414). */
|
||||
export async function moveRule(
|
||||
id: number, to: { topic_id: number } | { project_id: number },
|
||||
): Promise<Rule> {
|
||||
return apiPost(`/api/rules/${id}/move`, to);
|
||||
}
|
||||
|
||||
/** Draw a typed edge from one rule to another. Idempotent. */
|
||||
export async function relateRules(
|
||||
fromRuleId: number,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
deleteRule,
|
||||
} from "@/api/rulebooks";
|
||||
import type { ApplicableRules } from "@/api/rulebooks";
|
||||
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
||||
|
||||
/**
|
||||
* A project's view of its rules (milestone 414). A rule's home is its reach:
|
||||
@@ -206,6 +207,12 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<RuleHomePicker
|
||||
:rule-id="r.id"
|
||||
:topic-id="r.topic_id"
|
||||
:project-id="projectId"
|
||||
@moved="load"
|
||||
/>
|
||||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, ref, watch, onMounted } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
||||
import type { Rule } from "@/api/rulebooks";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
@@ -115,6 +117,14 @@ async function save() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
// A moved rule has left the topic this view lists (or joined another). The
|
||||
// store re-places it, then the editor saves any text edits and closes, the
|
||||
// way the backdrop does.
|
||||
async function onMoved(rule: Rule) {
|
||||
store.placeMovedRule(rule);
|
||||
await save();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (props.ruleId === null) return;
|
||||
if (!confirm("Delete this rule? This cannot be undone.")) return;
|
||||
@@ -210,6 +220,14 @@ watch(() => props.ruleId, load);
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<RuleHomePicker
|
||||
v-if="!isCreating && ruleId !== null && store.currentRule"
|
||||
:rule-id="ruleId"
|
||||
:topic-id="store.currentRule.topic_id"
|
||||
:project-id="store.currentRule.project_id"
|
||||
@moved="onMoved"
|
||||
/>
|
||||
|
||||
<section v-if="relations.length" class="relations">
|
||||
<h3>Related rules</h3>
|
||||
<ul>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Where a rule lives, and moving it (milestone 414).
|
||||
*
|
||||
* A rule's home IS its reach: in a rulebook topic it is global and applies to
|
||||
* every project; on a project it applies there alone. Moving keeps the rule's
|
||||
* id, edit history, areas and relations — the reason this is a move and not
|
||||
* "recreate it over there and delete this one".
|
||||
*
|
||||
* One component for both places a rule is read: the rule editor (a global
|
||||
* rule) and a project's rules tab (a project rule). Each would otherwise grow
|
||||
* its own picker, and the two would drift on what a destination is.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { apiErrorMessage, apiGet } from "@/api/client";
|
||||
import { listRulebooks, listTopics, moveRule, type Rule } from "@/api/rulebooks";
|
||||
|
||||
const props = defineProps<{
|
||||
ruleId: number;
|
||||
topicId: number | null;
|
||||
projectId: number | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ moved: [rule: Rule] }>();
|
||||
|
||||
interface TopicChoice { id: number; label: string }
|
||||
interface ProjectChoice { id: number; title: string }
|
||||
|
||||
const topics = ref<TopicChoice[]>([]);
|
||||
const projects = ref<ProjectChoice[]>([]);
|
||||
// "topic:12" / "project:4" — one select, two kinds of destination.
|
||||
const destination = ref("");
|
||||
const loading = ref(true);
|
||||
const moving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
if (props.topicId !== null) {
|
||||
const t = topics.value.find((x) => x.id === props.topicId);
|
||||
return t ? `Global — ${t.label}` : "Global";
|
||||
}
|
||||
const p = projects.value.find((x) => x.id === props.projectId);
|
||||
return p ? `Project — ${p.title}` : "Project";
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [books, proj] = await Promise.all([
|
||||
listRulebooks(),
|
||||
apiGet<{ projects: ProjectChoice[] }>("/api/projects"),
|
||||
]);
|
||||
const perBook = await Promise.all(books.map(async (rb) => {
|
||||
const ts = await listTopics(rb.id);
|
||||
return ts.map((t) => ({ id: t.id, label: `${rb.title} › ${t.title}` }));
|
||||
}));
|
||||
topics.value = perBook.flat();
|
||||
projects.value = proj.projects.map((p) => ({ id: p.id, title: p.title }));
|
||||
} catch (e: unknown) {
|
||||
error.value = apiErrorMessage(e, "Could not load where this rule could live");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function move() {
|
||||
const [kind, raw] = destination.value.split(":");
|
||||
const id = Number(raw);
|
||||
if (!id) return;
|
||||
const where = kind === "topic"
|
||||
? "global — it will apply to every project"
|
||||
: "this project's only — other projects will stop receiving it";
|
||||
if (!confirm(`Move this rule? It becomes ${where}.`)) return;
|
||||
moving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const rule = await moveRule(props.ruleId, kind === "topic" ? { topic_id: id } : { project_id: id });
|
||||
destination.value = "";
|
||||
emit("moved", rule);
|
||||
} catch (e: unknown) {
|
||||
error.value = apiErrorMessage(e, "Could not move the rule");
|
||||
} finally {
|
||||
moving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="rule-home">
|
||||
<legend>Where this rule applies</legend>
|
||||
<p v-if="loading" class="state-msg">Loading…</p>
|
||||
<template v-else>
|
||||
<p class="rule-home-current">{{ currentLabel }}</p>
|
||||
<div class="rule-home-move">
|
||||
<select v-model="destination" class="fs-input" aria-label="Move this rule to">
|
||||
<option value="">Move to…</option>
|
||||
<optgroup v-if="topics.length" label="Global (a rulebook topic)">
|
||||
<option
|
||||
v-for="t in topics.filter((x) => x.id !== topicId)"
|
||||
:key="`topic-${t.id}`"
|
||||
:value="`topic:${t.id}`"
|
||||
>{{ t.label }}</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="projects.length" label="One project">
|
||||
<option
|
||||
v-for="p in projects.filter((x) => x.id !== projectId)"
|
||||
:key="`project-${p.id}`"
|
||||
:value="`project:${p.id}`"
|
||||
>{{ p.title }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary btn-compact"
|
||||
:disabled="!destination || moving"
|
||||
@click="move"
|
||||
>{{ moving ? "Moving…" : "Move" }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rule-home { margin-bottom: 1rem; }
|
||||
.rule-home-current { margin: 0 0 0.5rem; font-size: 0.88rem; }
|
||||
.rule-home-move { display: flex; gap: var(--fs-space-2); align-items: center; }
|
||||
.rule-home-move select { flex: 1; min-width: 0; }
|
||||
</style>
|
||||
@@ -137,6 +137,20 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/** After a move (milestone 414): take the rule out of whichever topic list
|
||||
* held it, and into its new topic's list if that one is loaded. A rule moved
|
||||
* onto a project belongs to no topic list at all. */
|
||||
function placeMovedRule(rule: Rule) {
|
||||
if (currentRule.value?.id === rule.id) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const key = Number(tid);
|
||||
rulesByTopic.value[key] = rulesByTopic.value[key].filter((r) => r.id !== rule.id);
|
||||
}
|
||||
if (rule.topic_id !== null && rulesByTopic.value[rule.topic_id]) {
|
||||
rulesByTopic.value[rule.topic_id].push(toHeader(rule));
|
||||
}
|
||||
}
|
||||
|
||||
async function relateRules(
|
||||
fromRuleId: number,
|
||||
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
|
||||
@@ -198,6 +212,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
placeMovedRule,
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
|
||||
Reference in New Issue
Block a user