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
132 lines
4.5 KiB
Vue
132 lines
4.5 KiB
Vue
<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>
|