CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
Components, stores, api clients, router, types and shared CSS now state what each piece is and why, without the milestone that introduced it. diff.ts names its consumers instead of the copy history; stores/notes and tasks state their current scope instead of the cleanup that set it; theme.css states the token practice instead of the sweep that established it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
4.5 KiB
Vue
132 lines
4.5 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Where a rule lives, and moving it.
|
||
*
|
||
* 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>
|