feat(systems): the catalog reaches the moment a name is minted, and gets a face (#3028, milestone 307 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
Step 1 found the reason the standard names never held, and it is sharper than "prose doesn't fire": the list WAS real and it WAS seeded — but only on the inception path, for a project with zero Systems. Ad-hoc create_system never consulted it, which is how Forge minted "CI and Release" and Portal minted "CI & release" after the constant already existed. This wires the vocabulary to the moment that mints a name. - services/systems.assess_system_name: the local duplicate gate AND the catalog lookup, in ONE service function both doors call. The gate lived only in the MCP tool, which is exactly how the web UI shipped without a check the agent surface enforced (#2482). REST now answers 409 with the System that already covers the area. - An `exact` catalog hit is APPLIED (mechanical — the names differ only in spelling). An `overlap` is only OFFERED, on both doors: applying a judgment call silently is how a cross-project rule surfaces in the wrong project. - canonical_systems.best_overlap is the ONE scorer behind the create-time offer and the review sweep, so the two surfaces can never name different areas for one System. It also takes the catalog the caller already holds, so the review is not an N+1. UI (folded in from step 1 — rule 27, that step shipped with no human surface): - SystemsSection: a Shared area picker on create and edit, the area on each card, and a collapsed review of proposals that appears only when there is something to decide. `exact` and `overlap` never share a style — one is mechanical, the other is the reviewer's judgment, and presenting them alike is how a wrong mapping gets waved through. - Settings → Admin → Areas: the catalog itself, showing each entry's slug, because the slug is what decides whether two names are the same area and a rename moves it. - A picker rather than a live matcher: reproducing the slug rule in TypeScript would give this feature two matchers to keep in step — the exact drift the catalog exists to end. The server stays authoritative. tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an auto-MagicMock and therefore truthy, which is the trap that helper exists for (note 2109) and a nullable FK walks straight into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -12,6 +13,62 @@ import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
|
||||
// ── Shared areas (milestone 307) ────────────────────────────────────────
|
||||
// The global vocabulary a project's Systems map onto. Admin-only to WRITE —
|
||||
// a global list anyone can extend stops being a shared list — but every user
|
||||
// reads it, which is why the catalog lives in a store rather than here.
|
||||
const canonStore = useCanonicalSystemsStore();
|
||||
const newAreaName = ref("");
|
||||
const newAreaDescription = ref("");
|
||||
const creatingArea = ref(false);
|
||||
const editingAreaId = ref<number | null>(null);
|
||||
const editAreaName = ref("");
|
||||
const editAreaDescription = ref("");
|
||||
const savingArea = ref(false);
|
||||
|
||||
async function createArea() {
|
||||
const name = newAreaName.value.trim();
|
||||
if (!name || creatingArea.value) return;
|
||||
creatingArea.value = true;
|
||||
try {
|
||||
await canonStore.createEntry({
|
||||
name,
|
||||
description: newAreaDescription.value.trim() || undefined,
|
||||
});
|
||||
newAreaName.value = "";
|
||||
newAreaDescription.value = "";
|
||||
toastStore.show("Area added");
|
||||
} catch (e) {
|
||||
// A 409 means an area with the same match key already exists — say which,
|
||||
// because "CI and Release" vs "CI & Release" looks like a different name.
|
||||
toastStore.show(apiErrorMessage(e, "Failed to add area"), "error");
|
||||
} finally {
|
||||
creatingArea.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditArea(id: number, name: string, description: string | null) {
|
||||
editingAreaId.value = id;
|
||||
editAreaName.value = name;
|
||||
editAreaDescription.value = description ?? "";
|
||||
}
|
||||
|
||||
async function saveArea() {
|
||||
const id = editingAreaId.value;
|
||||
const name = editAreaName.value.trim();
|
||||
if (id == null || !name || savingArea.value) return;
|
||||
savingArea.value = true;
|
||||
try {
|
||||
await canonStore.updateEntry(id, { name, description: editAreaDescription.value.trim() });
|
||||
editingAreaId.value = null;
|
||||
toastStore.show("Area updated");
|
||||
} catch (e) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to update area"), "error");
|
||||
} finally {
|
||||
savingArea.value = false;
|
||||
}
|
||||
}
|
||||
const userTimezone = ref("");
|
||||
const savingTimezone = ref(false);
|
||||
const timezoneSaved = ref(false);
|
||||
@@ -134,7 +191,7 @@ const appVersion = ref('dev');
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -143,6 +200,7 @@ function _loadTabContent(tab: string) {
|
||||
if (tab === "users") loadUsersPanel();
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||
}
|
||||
if (tab === "apikeys") { fetchApiKeys(); }
|
||||
}
|
||||
@@ -1212,7 +1270,7 @@ async function deleteUser(userId: number) {
|
||||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||||
<div class="sidebar-group-label">Admin</div>
|
||||
<button
|
||||
v-for="tab in ['config', 'users', 'groups', 'logs']"
|
||||
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -2263,6 +2321,78 @@ async function deleteUser(userId: number) {
|
||||
</div>
|
||||
|
||||
<!-- ── Users ── -->
|
||||
<!-- ── Shared areas ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'areas'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>Shared areas</h2>
|
||||
<p class="field-hint">
|
||||
The vocabulary every project's Systems can be filed under, so the same word means the
|
||||
same thing everywhere. A project keeps its own name for an area — mapping records which
|
||||
shared area it is, it never renames anything. Editing a name here re-derives its match
|
||||
key, so existing mappings are kept but future name matching follows the new spelling.
|
||||
</p>
|
||||
|
||||
<ul class="area-admin-list">
|
||||
<li v-for="entry in canonStore.catalog" :key="entry.id" class="area-admin-row">
|
||||
<template v-if="editingAreaId === entry.id">
|
||||
<form class="area-admin-form" @submit.prevent="saveArea">
|
||||
<input v-model="editAreaName" class="fs-input" aria-label="Area name" />
|
||||
<textarea
|
||||
v-model="editAreaDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
aria-label="Area description"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editAreaName.trim() || savingArea">
|
||||
{{ savingArea ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="editingAreaId = null">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="area-admin-body">
|
||||
<div class="area-admin-name-row">
|
||||
<span class="area-admin-name">{{ entry.name }}</span>
|
||||
<code class="area-admin-slug" title="The match key. Names that reduce to this are the same area.">{{ entry.slug }}</code>
|
||||
</div>
|
||||
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="startEditArea(entry.id, entry.name, entry.description)"
|
||||
>Edit</button>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!canonStore.catalog.length && !canonStore.loading" class="settings-empty">
|
||||
No areas yet.
|
||||
</p>
|
||||
|
||||
<form class="area-admin-form area-admin-create" @submit.prevent="createArea">
|
||||
<input
|
||||
v-model="newAreaName"
|
||||
class="fs-input"
|
||||
placeholder="New area name (e.g. Search & Indexing)"
|
||||
aria-label="New area name"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newAreaDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
placeholder="What belongs in this area? One paragraph — a bare name is never enough."
|
||||
aria-label="New area description"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newAreaName.trim() || creatingArea">
|
||||
{{ creatingArea ? "Adding…" : "Add area" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
@@ -3401,4 +3531,34 @@ async function deleteUser(userId: number) {
|
||||
color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Shared areas (milestone 307) ──────────────────────────────────
|
||||
The slug is shown deliberately: it is what decides whether two names are
|
||||
the same area, and an admin renaming an entry needs to see it move. */
|
||||
.area-admin-list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.area-admin-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
padding: var(--fs-space-3);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.area-admin-body { flex: 1; min-width: 0; }
|
||||
.area-admin-name-row { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.area-admin-name { color: var(--fs-text-primary); }
|
||||
.area-admin-slug {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.area-admin-desc { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
|
||||
.area-admin-create { margin-top: var(--fs-space-4); }
|
||||
.area-admin-actions { display: flex; gap: 0.4rem; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user