feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
A project's platforms decide which family ideas reach it. This step makes membership answerable from every door: - services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one. - coverage: the archive scan now carries every path, and the refresh runs detection fail-open. - inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected. - MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms. - REST: /api/platforms (admin writes) and /api/projects/<id>/platforms. - UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
/** Project inception (milestone 297): what a project was decided to inherit. */
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import type { ProjectPlatform } from "@/api/platforms";
|
||||
|
||||
export interface InceptionChoices {
|
||||
design_system_id: number | null;
|
||||
seed_systems: boolean;
|
||||
/** Platform slugs (milestone 463). null = not answered; a list is the
|
||||
* whole answer — members left out of it are recorded as "not this".
|
||||
* Absent on records decided before the question existed. */
|
||||
platforms?: string[] | null;
|
||||
}
|
||||
|
||||
export interface InceptionRecord {
|
||||
@@ -17,16 +22,24 @@ export interface InceptionDefaults {
|
||||
design_system_id: number | null;
|
||||
design_systems: { id: number; title: string }[];
|
||||
systems: number;
|
||||
platforms: { slug: string; name: string }[];
|
||||
/** What detection (or an earlier answer) already says about the project. */
|
||||
project_platforms: ProjectPlatform[];
|
||||
}
|
||||
|
||||
export interface InceptionDecision {
|
||||
project_id: number;
|
||||
inception: InceptionRecord;
|
||||
effects: { design_system_id: number | null; systems_seeded: string[] };
|
||||
effects: {
|
||||
design_system_id: number | null;
|
||||
systems_seeded: string[];
|
||||
/** The project's answers after the decision; null when left unstated. */
|
||||
platforms: ProjectPlatform[] | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const emptyChoices = (): InceptionChoices => ({
|
||||
design_system_id: null, seed_systems: false,
|
||||
design_system_id: null, seed_systems: false, platforms: null,
|
||||
});
|
||||
|
||||
export const fetchInceptionDefaults = (projectId: number) =>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Platforms — what a project is built on or ships as (milestone 463).
|
||||
*
|
||||
* The catalog is GLOBAL, like the canonical areas, and admin-written. A
|
||||
* project's answer per platform is `declared` (a person said so), `detected`
|
||||
* (a marker file in a bound repo said so) or `rejected` (a person said no —
|
||||
* kept, so the next refresh cannot detect it back). Only declared and
|
||||
* detected make a project a member, and membership is what decides which
|
||||
* family ideas reach it.
|
||||
*/
|
||||
import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
|
||||
|
||||
export interface Platform {
|
||||
id: number;
|
||||
name: string;
|
||||
/** The match key, and the name every door takes (stable across restores). */
|
||||
slug: string;
|
||||
description: string | null;
|
||||
/** Repo-relative globs. A bare name matches a basename anywhere; one with
|
||||
* a slash matches the whole path. Empty = declare-only. */
|
||||
markers: string[];
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export type PlatformState = "declared" | "detected" | "rejected";
|
||||
/** What a person may set. `null` withdraws the answer. */
|
||||
export type SettablePlatformState = "declared" | "rejected" | null;
|
||||
|
||||
export interface ProjectPlatform {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
state: PlatformState;
|
||||
}
|
||||
|
||||
export const MEMBER_STATES: PlatformState[] = ["declared", "detected"];
|
||||
|
||||
export async function listPlatforms(): Promise<Platform[]> {
|
||||
const data = await apiGet<{ platforms: Platform[] }>("/api/platforms");
|
||||
return data.platforms;
|
||||
}
|
||||
|
||||
/** Admin only. A name that reduces to an existing slug answers 409. */
|
||||
export async function createPlatform(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
markers?: string[];
|
||||
}): Promise<Platform> {
|
||||
return apiPost("/api/platforms", data);
|
||||
}
|
||||
|
||||
export async function updatePlatform(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
|
||||
): Promise<Platform> {
|
||||
return apiPatch(`/api/platforms/${id}`, data);
|
||||
}
|
||||
|
||||
export async function fetchProjectPlatforms(projectId: number): Promise<ProjectPlatform[]> {
|
||||
const data = await apiGet<{ project_platforms: ProjectPlatform[] }>(
|
||||
`/api/projects/${projectId}/platforms`,
|
||||
);
|
||||
return data.project_platforms;
|
||||
}
|
||||
|
||||
/** Only the slugs named change; the update applies whole or not at all. */
|
||||
export async function setProjectPlatforms(
|
||||
projectId: number,
|
||||
platforms: Record<string, SettablePlatformState>,
|
||||
): Promise<ProjectPlatform[]> {
|
||||
const data = await apiPut<{ project_platforms: ProjectPlatform[] }>(
|
||||
`/api/projects/${projectId}/platforms`, { platforms },
|
||||
);
|
||||
return data.project_platforms;
|
||||
}
|
||||
@@ -6,10 +6,16 @@
|
||||
* second step and only emits the choices (the project does not exist yet);
|
||||
* mode="decide" sits on ProjectView for an undecided project, loads that
|
||||
* project's current defaults, and records the decision itself.
|
||||
*
|
||||
* Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes
|
||||
* the list the whole answer; in decide mode the boxes start from what
|
||||
* detection already found, so recording confirms it.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { fetchDesignSystems } from "@/api/designSystems";
|
||||
import { MEMBER_STATES } from "@/api/platforms";
|
||||
import { usePlatformsStore } from "@/stores/platforms";
|
||||
import {
|
||||
decideInception, emptyChoices, fetchInceptionDefaults,
|
||||
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
|
||||
@@ -29,6 +35,9 @@ const emit = defineEmits<{
|
||||
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||
const systemsCount = ref(0);
|
||||
const platforms = ref<{ slug: string; name: string }[]>([]);
|
||||
const detected = ref<Set<string>>(new Set());
|
||||
const platformsStore = usePlatformsStore();
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
@@ -46,11 +55,25 @@ async function load() {
|
||||
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||
designSystems.value = d.design_systems;
|
||||
systemsCount.value = d.systems;
|
||||
platforms.value = d.platforms;
|
||||
const members = d.project_platforms
|
||||
.filter((p) => MEMBER_STATES.includes(p.state))
|
||||
.map((p) => p.slug);
|
||||
detected.value = new Set(
|
||||
d.project_platforms.filter((p) => p.state === "detected").map((p) => p.slug),
|
||||
);
|
||||
// Start from what stands today, so "record" without changes keeps it.
|
||||
local.value = { design_system_id: d.design_system_id, seed_systems: false };
|
||||
local.value = {
|
||||
design_system_id: d.design_system_id,
|
||||
seed_systems: false,
|
||||
platforms: members.length ? members : null,
|
||||
};
|
||||
} else {
|
||||
const ds = await fetchDesignSystems();
|
||||
const [ds, catalog] = await Promise.all([
|
||||
fetchDesignSystems(), platformsStore.fetchCatalog(),
|
||||
]);
|
||||
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||
platforms.value = catalog.map((p) => ({ slug: p.slug, name: p.name }));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
|
||||
@@ -61,6 +84,22 @@ async function load() {
|
||||
|
||||
const nothingToDecide = computed(() => !designSystems.value.length);
|
||||
|
||||
function isChecked(slug: string): boolean {
|
||||
return (local.value.platforms ?? []).includes(slug);
|
||||
}
|
||||
|
||||
function togglePlatform(slug: string, on: boolean) {
|
||||
const current = new Set(local.value.platforms ?? []);
|
||||
if (on) current.add(slug);
|
||||
else current.delete(slug);
|
||||
local.value.platforms = [...current].sort();
|
||||
}
|
||||
|
||||
/** Back to "not answered" — distinct from an empty list, which says "none". */
|
||||
function clearPlatforms() {
|
||||
local.value.platforms = null;
|
||||
}
|
||||
|
||||
async function record() {
|
||||
if (!props.projectId) return;
|
||||
saving.value = true;
|
||||
@@ -105,6 +144,31 @@ onMounted(load);
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="platforms.length" class="inception-group">
|
||||
<h4>Platforms</h4>
|
||||
<p class="inception-muted">
|
||||
What it is built on or ships as. Ideas the family has proven for a
|
||||
platform reach every project that is one.
|
||||
<template v-if="local.platforms === null"> Not answered yet.</template>
|
||||
<template v-else>
|
||||
Anything left unticked is recorded as "not this project".
|
||||
<button type="button" class="btn-ghost btn-sm" @click="clearPlatforms">Leave unanswered</button>
|
||||
</template>
|
||||
</p>
|
||||
<div class="inception-platforms">
|
||||
<label v-for="p in platforms" :key="p.slug" class="inception-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isChecked(p.slug)"
|
||||
@change="togglePlatform(p.slug, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>
|
||||
{{ p.name }}
|
||||
<em v-if="detected.has(p.slug)" class="inception-muted"> — detected</em>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="nothingToDecide" class="inception-muted">
|
||||
No design systems on this install yet — recording still settles the question.
|
||||
</p>
|
||||
@@ -132,6 +196,11 @@ onMounted(load);
|
||||
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
|
||||
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
|
||||
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||
.inception-platforms {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||
gap: 0 1rem;
|
||||
}
|
||||
.inception-select {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A project's place in the family (milestone 463): which platforms it is.
|
||||
*
|
||||
* Membership is what makes "does this family idea reach this project?" a
|
||||
* lookup rather than a judgment — an idea is for some platforms, and it
|
||||
* reaches every project that is one of them. So the answers here matter, and
|
||||
* each kind of answer is shown as what it is:
|
||||
*
|
||||
* - Yes — a person said so.
|
||||
* - Detected — a marker file in a bound repo said so. Detection only adds,
|
||||
* and only where nobody has answered; it never overrides a Yes or a No.
|
||||
* - No — a person said this project is NOT that platform. Kept as a row, so
|
||||
* the next coverage refresh cannot detect it straight back.
|
||||
* - No answer — detection may decide on the next refresh.
|
||||
*
|
||||
* Each change is saved as it is made: one platform, one request, applied
|
||||
* whole or refused whole.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import {
|
||||
fetchProjectPlatforms, setProjectPlatforms,
|
||||
type PlatformState, type ProjectPlatform, type SettablePlatformState,
|
||||
} from "@/api/platforms";
|
||||
import { usePlatformsStore } from "@/stores/platforms";
|
||||
|
||||
const props = defineProps<{ projectId: number; canWrite: boolean }>();
|
||||
|
||||
const platformsStore = usePlatformsStore();
|
||||
const answers = ref<ProjectPlatform[]>([]);
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
const saving = ref<string | null>(null);
|
||||
const error = ref("");
|
||||
|
||||
const LABELS: Record<PlatformState, string> = {
|
||||
declared: "Yes",
|
||||
detected: "Detected",
|
||||
rejected: "No",
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const [, rows] = await Promise.all([
|
||||
platformsStore.fetchCatalog(),
|
||||
fetchProjectPlatforms(props.projectId),
|
||||
]);
|
||||
answers.value = rows;
|
||||
} catch {
|
||||
// Said out loud: "couldn't load" must not read as "no platforms".
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const stateBySlug = computed(() => {
|
||||
const out: Record<string, PlatformState> = {};
|
||||
for (const a of answers.value) out[a.slug] = a.state;
|
||||
return out;
|
||||
});
|
||||
|
||||
const rows = computed(() =>
|
||||
platformsStore.catalog.map((p) => ({
|
||||
...p,
|
||||
state: stateBySlug.value[p.slug] ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
const memberCount = computed(
|
||||
() => answers.value.filter((a) => a.state !== "rejected").length,
|
||||
);
|
||||
|
||||
async function answer(slug: string, value: string) {
|
||||
const state: SettablePlatformState = value === "" ? null : (value as SettablePlatformState);
|
||||
saving.value = slug;
|
||||
error.value = "";
|
||||
try {
|
||||
answers.value = await setProjectPlatforms(props.projectId, { [slug]: state });
|
||||
} catch (e: unknown) {
|
||||
error.value = apiErrorMessage(e, "Could not save that answer");
|
||||
} finally {
|
||||
saving.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.projectId, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pft">
|
||||
<header class="pft-head">
|
||||
<h3 class="pft-title">Platforms</h3>
|
||||
<p class="pft-muted">
|
||||
What this project is built on or ships as. Ideas the family has proven
|
||||
for a platform reach every project that is one, and each project
|
||||
answers them — adopts, varies with a reason, or owes the work.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="loading" class="pft-muted">Loading…</p>
|
||||
|
||||
<div v-else-if="failed" class="pft-note">
|
||||
<strong>The platforms couldn't load.</strong>
|
||||
<p>Nothing is known either way — this is a failure, not an empty answer.</p>
|
||||
<button class="btn-secondary btn-sm" @click="load">Try again</button>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!rows.length" class="pft-muted">
|
||||
This install has no platforms in its catalog yet. An administrator adds
|
||||
them in Settings.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="pft-summary">
|
||||
<template v-if="memberCount">
|
||||
A member of {{ memberCount }} platform{{ memberCount === 1 ? "" : "s" }}.
|
||||
</template>
|
||||
<template v-else>
|
||||
Not a member of any platform yet — bind a repo and refresh coverage
|
||||
to detect them, or answer below.
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<ul class="pft-list">
|
||||
<li v-for="p in rows" :key="p.id" class="pft-row">
|
||||
<div class="pft-name">
|
||||
<span>{{ p.name }}</span>
|
||||
<span v-if="p.state" :class="['pft-tag', `pft-${p.state}`]">{{ LABELS[p.state] }}</span>
|
||||
<span v-if="p.description" class="pft-desc">{{ p.description }}</span>
|
||||
</div>
|
||||
<select
|
||||
v-if="canWrite"
|
||||
class="pft-select"
|
||||
:value="p.state === 'detected' ? 'detected' : (p.state ?? '')"
|
||||
:disabled="saving === p.slug"
|
||||
:aria-label="`Is this project ${p.name}?`"
|
||||
@change="answer(p.slug, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-if="p.state === 'detected'" value="detected" disabled>Detected</option>
|
||||
<option value="declared">Yes</option>
|
||||
<option value="rejected">No</option>
|
||||
<option value="">No answer</option>
|
||||
</select>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pft { padding: 0.25rem 0; }
|
||||
.pft-head { margin-bottom: 1rem; }
|
||||
.pft-title { margin: 0 0 0.35rem; font-size: 1rem; }
|
||||
.pft-muted { color: var(--fs-text-tertiary); font-size: 0.875rem; margin: 0; }
|
||||
.pft-summary { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 0.75rem; }
|
||||
.pft-note {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.pft-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
|
||||
.pft-list { list-style: none; margin: 0; padding: 0; }
|
||||
.pft-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.pft-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; min-width: 0; }
|
||||
.pft-desc { flex-basis: 100%; color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||||
.pft-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.pft-declared { color: var(--fs-text-primary); }
|
||||
.pft-rejected { color: var(--fs-text-tertiary); text-decoration: line-through; }
|
||||
.pft-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.pft-row { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import * as api from "@/api/platforms";
|
||||
import type { Platform } from "@/api/platforms";
|
||||
|
||||
/**
|
||||
* The global platform catalog (milestone 463). Shared by every project, so
|
||||
* it is fetched once per session — as the canonical-area catalog is.
|
||||
*/
|
||||
export const usePlatformsStore = defineStore("platforms", () => {
|
||||
const catalog = ref<Platform[]>([]);
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchCatalog(force = false) {
|
||||
if (loaded.value && !force) return catalog.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
catalog.value = await api.listPlatforms();
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
// The catalog is a vocabulary for a form; an empty one degrades the
|
||||
// form rather than failing the screen it sits on.
|
||||
catalog.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return catalog.value;
|
||||
}
|
||||
|
||||
async function createEntry(data: { name: string; description?: string; markers?: string[] }) {
|
||||
const entry = await api.createPlatform(data);
|
||||
catalog.value.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function updateEntry(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
|
||||
) {
|
||||
const entry = await api.updatePlatform(id, data);
|
||||
const idx = catalog.value.findIndex((p) => p.id === id);
|
||||
if (idx >= 0) catalog.value[idx] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
return { catalog, loaded, loading, fetchCatalog, createEntry, updateEntry };
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import KindBadge from "@/components/KindBadge.vue";
|
||||
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||
import ProjectFamilyTab from "@/components/ProjectFamilyTab.vue";
|
||||
import { canWriteRecord } from "@/utils/permission";
|
||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||
import SystemsSection from "@/components/SystemsSection.vue";
|
||||
import InceptionCard from "@/components/InceptionCard.vue";
|
||||
@@ -125,7 +127,7 @@ async function confirmStartPlanning() {
|
||||
const saving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
|
||||
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks");
|
||||
|
||||
const tasks = ref<NoteItem[]>([]);
|
||||
const notes = ref<NoteItem[]>([]);
|
||||
@@ -747,6 +749,10 @@ async function confirmDelete() {
|
||||
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
||||
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
|
||||
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
|
||||
<!-- What was said AT inception; the live answers are on the Family tab. -->
|
||||
<template v-if="project.inception.choices.platforms?.length">
|
||||
· platforms {{ project.inception.choices.platforms.join(", ") }}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<!-- Summary stat chips -->
|
||||
@@ -945,6 +951,9 @@ async function confirmDelete() {
|
||||
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
|
||||
Design
|
||||
</button>
|
||||
<button :class="['tab-btn', { active: activeTab === 'family' }]" @click="activeTab = 'family'">
|
||||
Family
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tasks tab — milestone-grouped kanban -->
|
||||
@@ -1190,6 +1199,13 @@ async function confirmDelete() {
|
||||
:project-id="projectId"
|
||||
:design-system-id="project.design_system_id ?? null"
|
||||
/>
|
||||
|
||||
<!-- Family tab (milestone 463): which platforms this project is. -->
|
||||
<ProjectFamilyTab
|
||||
v-if="activeTab === 'family'"
|
||||
:project-id="projectId"
|
||||
:can-write="canWriteRecord(project.permission)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { usePlatformsStore } from "@/stores/platforms";
|
||||
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";
|
||||
@@ -71,6 +72,75 @@ async function saveArea() {
|
||||
savingArea.value = false;
|
||||
}
|
||||
}
|
||||
// ── Platforms (milestone 463) ───────────────────────────────────────────
|
||||
// The global list of what a project can be built on or ship as. Same split
|
||||
// as the areas: admin-only to write, readable by everyone. Markers are the
|
||||
// repo-relative globs that let a coverage refresh DETECT a platform; they
|
||||
// are edited one per line, since a glob may itself contain a comma.
|
||||
const platformsStore = usePlatformsStore();
|
||||
const newPlatformName = ref("");
|
||||
const newPlatformDescription = ref("");
|
||||
const newPlatformMarkers = ref("");
|
||||
const creatingPlatform = ref(false);
|
||||
const editingPlatformId = ref<number | null>(null);
|
||||
const editPlatformName = ref("");
|
||||
const editPlatformDescription = ref("");
|
||||
const editPlatformMarkers = ref("");
|
||||
const savingPlatform = ref(false);
|
||||
|
||||
function markerLines(text: string): string[] {
|
||||
return text.split("\n").map((m) => m.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function createPlatform() {
|
||||
const name = newPlatformName.value.trim();
|
||||
if (!name || creatingPlatform.value) return;
|
||||
creatingPlatform.value = true;
|
||||
try {
|
||||
await platformsStore.createEntry({
|
||||
name,
|
||||
description: newPlatformDescription.value.trim() || undefined,
|
||||
markers: markerLines(newPlatformMarkers.value),
|
||||
});
|
||||
newPlatformName.value = "";
|
||||
newPlatformDescription.value = "";
|
||||
newPlatformMarkers.value = "";
|
||||
toastStore.show("Platform added");
|
||||
} catch (e) {
|
||||
// A 409 names the existing platform the new name reduces to.
|
||||
toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error");
|
||||
} finally {
|
||||
creatingPlatform.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) {
|
||||
editingPlatformId.value = id;
|
||||
editPlatformName.value = name;
|
||||
editPlatformDescription.value = description ?? "";
|
||||
editPlatformMarkers.value = markers.join("\n");
|
||||
}
|
||||
|
||||
async function savePlatform() {
|
||||
const id = editingPlatformId.value;
|
||||
const name = editPlatformName.value.trim();
|
||||
if (id == null || !name || savingPlatform.value) return;
|
||||
savingPlatform.value = true;
|
||||
try {
|
||||
await platformsStore.updateEntry(id, {
|
||||
name,
|
||||
description: editPlatformDescription.value.trim(),
|
||||
markers: markerLines(editPlatformMarkers.value),
|
||||
});
|
||||
editingPlatformId.value = null;
|
||||
toastStore.show("Platform updated");
|
||||
} catch (e) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error");
|
||||
} finally {
|
||||
savingPlatform.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const userTimezone = ref("");
|
||||
const savingTimezone = ref(false);
|
||||
const timezoneSaved = ref(false);
|
||||
@@ -479,7 +549,7 @@ async function copyCommit() {
|
||||
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", "areas"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -489,6 +559,7 @@ function _loadTabContent(tab: string) {
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||
else if (tab === "platforms") platformsStore.fetchCatalog(true);
|
||||
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
||||
}
|
||||
if (tab === "apikeys") { fetchApiKeys(); }
|
||||
@@ -1611,7 +1682,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', 'areas', 'users', 'groups', 'logs']"
|
||||
v-for="tab in ['config', 'areas', 'platforms', 'users', 'groups', 'logs']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -3179,6 +3250,99 @@ async function deleteUser(userId: number) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Platforms ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'platforms'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>Platforms</h2>
|
||||
<p class="field-hint">
|
||||
What a project can be built on or ship as. A project's platforms decide which family
|
||||
ideas reach it. Markers are repo-relative file patterns, one per line: a bare name
|
||||
(<code>go.mod</code>) matches that file anywhere in a bound repo, and a pattern with a
|
||||
slash (<code>.github/workflows/*</code>) matches the whole path. A platform with no
|
||||
markers is never detected — projects declare it themselves.
|
||||
</p>
|
||||
|
||||
<ul class="area-admin-list">
|
||||
<li v-for="entry in platformsStore.catalog" :key="entry.id" class="area-admin-row">
|
||||
<template v-if="editingPlatformId === entry.id">
|
||||
<form class="area-admin-form" @submit.prevent="savePlatform">
|
||||
<input v-model="editPlatformName" class="fs-input" aria-label="Platform name" />
|
||||
<textarea
|
||||
v-model="editPlatformDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
aria-label="Platform description"
|
||||
></textarea>
|
||||
<textarea
|
||||
v-model="editPlatformMarkers"
|
||||
class="fs-input area-admin-markers"
|
||||
rows="3"
|
||||
aria-label="Detection markers, one per line"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editPlatformName.trim() || savingPlatform">
|
||||
{{ savingPlatform ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="editingPlatformId = 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, and the name tools and backups use.">{{ entry.slug }}</code>
|
||||
</div>
|
||||
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
|
||||
<p class="area-admin-desc">
|
||||
<template v-if="entry.markers.length">
|
||||
Detected by
|
||||
<code v-for="m in entry.markers" :key="m" class="area-admin-slug">{{ m }}</code>
|
||||
</template>
|
||||
<template v-else>Declare-only — never detected.</template>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="startEditPlatform(entry.id, entry.name, entry.description, entry.markers)"
|
||||
>Edit</button>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!platformsStore.catalog.length && !platformsStore.loading" class="settings-empty">
|
||||
No platforms yet.
|
||||
</p>
|
||||
|
||||
<form class="area-admin-form area-admin-create" @submit.prevent="createPlatform">
|
||||
<input
|
||||
v-model="newPlatformName"
|
||||
class="fs-input"
|
||||
placeholder="New platform name (e.g. Flutter app)"
|
||||
aria-label="New platform name"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newPlatformDescription"
|
||||
class="fs-input"
|
||||
rows="2"
|
||||
placeholder="What makes a project this platform? One sentence."
|
||||
aria-label="New platform description"
|
||||
></textarea>
|
||||
<textarea
|
||||
v-model="newPlatformMarkers"
|
||||
class="fs-input area-admin-markers"
|
||||
rows="2"
|
||||
placeholder="Markers, one per line (e.g. pubspec.yaml) — leave empty for declare-only"
|
||||
aria-label="New platform markers, one per line"
|
||||
></textarea>
|
||||
<div class="area-admin-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newPlatformName.trim() || creatingPlatform">
|
||||
{{ creatingPlatform ? "Adding…" : "Add platform" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
@@ -4386,6 +4550,8 @@ async function deleteUser(userId: number) {
|
||||
.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; }
|
||||
.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; }
|
||||
.area-admin-desc .area-admin-slug { margin-right: 0.25rem; }
|
||||
|
||||
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
|
||||
the operator is reviewing what was done, and the reason is the part worth
|
||||
|
||||
Reference in New Issue
Block a user