feat(inception): UI — New-project modal step 2, InceptionCard on the project page, Rules tab shows excluded always-on rulebooks; REST exclusion routes (#2883, milestone 297 step 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
- components/InceptionCard.vue: the one form, two homes — mode="create" in the New-project modal's second step (emits the choices; the create carries `inception`), mode="decide" on ProjectView for the owner of an undecided project (loads that project's defaults, records the decision). Always-on rulebooks listed checked (uncheck = exclude), others unchecked (check = subscribe), design system select, seed-Systems toggle (disabled once the project has Systems). Tokens only; modal canon (#2855); .btn-* canon. - ProjectView: the card while undecided, one "Inheritance decided <date> via … · …" line after; onDecided refreshes the project. - ProjectRulesTab: "Excluded always-on rulebooks" section with include-back. - api/inception.ts (types, fetchInceptionDefaults, decideInception); api/rulebooks.ts: ApplicableRules.excluded_always_on, exclude/include wrappers; REST POST/DELETE /api/projects/<id>/exclusions/rulebooks/<rb>. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
/** Project inception (milestone 297): what a project was decided to inherit. */
|
||||||
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
|
|
||||||
|
export interface InceptionChoices {
|
||||||
|
exclude_always_on_rulebooks: number[];
|
||||||
|
subscribe_rulebooks: number[];
|
||||||
|
design_system_id: number | null;
|
||||||
|
seed_systems: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InceptionRecord {
|
||||||
|
decided_at: string;
|
||||||
|
decided_by: number | null;
|
||||||
|
via: "mcp" | "ui" | "legacy";
|
||||||
|
choices: InceptionChoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InceptionDefaults {
|
||||||
|
always_on_rulebooks: { id: number; title: string }[];
|
||||||
|
other_rulebooks: { id: number; title: string }[];
|
||||||
|
excluded_always_on: { id: number; title: string }[];
|
||||||
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
|
design_system_id: number | null;
|
||||||
|
design_systems: { id: number; title: string }[];
|
||||||
|
systems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InceptionDecision {
|
||||||
|
project_id: number;
|
||||||
|
inception: InceptionRecord;
|
||||||
|
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyChoices = (): InceptionChoices => ({
|
||||||
|
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInceptionDefaults = (projectId: number) =>
|
||||||
|
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
|
||||||
|
|
||||||
|
export const decideInception = (projectId: number, choices: InceptionChoices) =>
|
||||||
|
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
|
||||||
@@ -71,6 +71,8 @@ export interface ApplicableRules {
|
|||||||
}[];
|
}[];
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
subscribed_rulebooks: { id: number; title: string }[];
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
|
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||||
|
excluded_always_on: { id: number; title: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||||
@@ -181,3 +183,14 @@ export async function suppressTopicForProject(projectId: number, topicId: number
|
|||||||
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
|
||||||
|
|
||||||
|
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||||
|
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||||
|
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The inception form (milestone 297): "what does this project inherit?"
|
||||||
|
*
|
||||||
|
* Two homes, one component. mode="create" rides the New-project modal's
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { fetchDesignSystems } from "@/api/designSystems";
|
||||||
|
import {
|
||||||
|
decideInception, emptyChoices, fetchInceptionDefaults,
|
||||||
|
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
|
||||||
|
} from "@/api/inception";
|
||||||
|
import { listRulebooks } from "@/api/rulebooks";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
mode: "create" | "decide";
|
||||||
|
projectId?: number;
|
||||||
|
choices?: InceptionChoices;
|
||||||
|
}>(), { projectId: 0, choices: undefined });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:choices": [value: InceptionChoices];
|
||||||
|
decided: [decision: InceptionDecision];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||||
|
const alwaysOn = ref<{ id: number; title: string }[]>([]);
|
||||||
|
const others = ref<{ id: number; title: string }[]>([]);
|
||||||
|
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||||
|
const systemsCount = ref(0);
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
function emitChoices() {
|
||||||
|
emit("update:choices", { ...local.value });
|
||||||
|
}
|
||||||
|
watch(local, emitChoices, { deep: true });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
if (props.mode === "decide" && props.projectId) {
|
||||||
|
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||||
|
alwaysOn.value = d.always_on_rulebooks;
|
||||||
|
others.value = d.other_rulebooks;
|
||||||
|
designSystems.value = d.design_systems;
|
||||||
|
systemsCount.value = d.systems;
|
||||||
|
// Start from what stands today so "record" without changes is a true inherit-all.
|
||||||
|
local.value = {
|
||||||
|
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
|
||||||
|
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
||||||
|
design_system_id: d.design_system_id,
|
||||||
|
seed_systems: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
|
||||||
|
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||||
|
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||||
|
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inherits(id: number): boolean {
|
||||||
|
return !local.value.exclude_always_on_rulebooks.includes(id);
|
||||||
|
}
|
||||||
|
function toggleInherit(id: number) {
|
||||||
|
const list = local.value.exclude_always_on_rulebooks;
|
||||||
|
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||||
|
}
|
||||||
|
function subscribed(id: number): boolean {
|
||||||
|
return local.value.subscribe_rulebooks.includes(id);
|
||||||
|
}
|
||||||
|
function toggleSubscribe(id: number) {
|
||||||
|
const list = local.value.subscribe_rulebooks;
|
||||||
|
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nothingToDecide = computed(
|
||||||
|
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function record() {
|
||||||
|
if (!props.projectId) return;
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const decision = await decideInception(props.projectId, local.value);
|
||||||
|
emit("decided", decision);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = apiErrorMessage(e, "Could not record the decision");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="inception" aria-labelledby="inception-title">
|
||||||
|
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
|
||||||
|
<p class="inception-lede">
|
||||||
|
A project's inheritance is a decision, not a default. Until it is recorded,
|
||||||
|
every always-on rulebook binds, nothing is subscribed, and there is no design
|
||||||
|
system or Systems.
|
||||||
|
</p>
|
||||||
|
<p v-if="loading" class="inception-muted">Loading…</p>
|
||||||
|
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="alwaysOn.length" class="inception-group">
|
||||||
|
<h4>Always-on rulebooks</h4>
|
||||||
|
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
|
||||||
|
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
|
||||||
|
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
|
||||||
|
<span>{{ rb.title }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="others.length" class="inception-group">
|
||||||
|
<h4>Subscribe to rulebooks</h4>
|
||||||
|
<label v-for="rb in others" :key="rb.id" class="inception-choice">
|
||||||
|
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
||||||
|
<span>{{ rb.title }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="inception-group">
|
||||||
|
<h4>Design system</h4>
|
||||||
|
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
|
||||||
|
<option :value="null">None</option>
|
||||||
|
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="inception-group">
|
||||||
|
<label class="inception-choice">
|
||||||
|
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
|
||||||
|
<span>
|
||||||
|
Seed the standard starter Systems (CI & Release, Auth & Access, Data Model & Storage, …)
|
||||||
|
<em v-if="systemsCount > 0" class="inception-muted"> — this project already has {{ systemsCount }}</em>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="nothingToDecide" class="inception-muted">
|
||||||
|
Nothing to inherit yet on this install — recording still settles the question.
|
||||||
|
</p>
|
||||||
|
<div v-if="mode === 'decide'" class="inception-actions">
|
||||||
|
<button class="btn-primary" :disabled="saving" @click="record">
|
||||||
|
{{ saving ? "Recording…" : "Record decision" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inception {
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-lg);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.inception-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
|
||||||
|
.inception-lede { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
|
||||||
|
.inception-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0 0 0.35rem; }
|
||||||
|
.inception-group { margin-bottom: 1rem; }
|
||||||
|
.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-select {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
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.9rem;
|
||||||
|
}
|
||||||
|
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
|
||||||
|
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
@@ -2,10 +2,18 @@
|
|||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
getProjectApplicableRules,
|
||||||
listRulebooks, getRule, createProjectRule, deleteRule,
|
subscribeProject,
|
||||||
suppressRuleForProject, unsuppressRuleForProject,
|
unsubscribeProject,
|
||||||
suppressTopicForProject, unsuppressTopicForProject,
|
listRulebooks,
|
||||||
|
getRule,
|
||||||
|
createProjectRule,
|
||||||
|
deleteRule,
|
||||||
|
suppressRuleForProject,
|
||||||
|
unsuppressRuleForProject,
|
||||||
|
suppressTopicForProject,
|
||||||
|
unsuppressTopicForProject,
|
||||||
|
includeAlwaysOnRulebook,
|
||||||
} from "@/api/rulebooks";
|
} from "@/api/rulebooks";
|
||||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
@@ -35,6 +43,11 @@ async function subscribe(rulebookId: number) {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function includeBack(rulebookId: number) {
|
||||||
|
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
async function unsubscribe(rulebookId: number) {
|
async function unsubscribe(rulebookId: number) {
|
||||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||||
await unsubscribeProject(props.projectId, rulebookId);
|
await unsubscribeProject(props.projectId, rulebookId);
|
||||||
@@ -172,6 +185,17 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="applicable.excluded_always_on?.length" class="excluded">
|
||||||
|
<h3>Excluded always-on rulebooks</h3>
|
||||||
|
<p class="excluded-note">Opted out at inception — these do not bind this project.</p>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
|
||||||
|
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
||||||
|
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again">↩</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="project-rules">
|
<section class="project-rules">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>Project rules</h3>
|
<h3>Project rules</h3>
|
||||||
@@ -321,6 +345,9 @@ watch(() => props.projectId, load);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||||
|
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||||
|
.chip-excluded .chip-remove { text-decoration: none; }
|
||||||
.rules-tab { padding: 1rem; }
|
.rules-tab { padding: 1rem; }
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||||
|
import { emptyChoices, type InceptionChoices } from "@/api/inception";
|
||||||
|
import InceptionCard from "@/components/InceptionCard.vue";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { milestoneColor } from "@/utils/palette";
|
import { milestoneColor } from "@/utils/palette";
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ const newTitle = ref("");
|
|||||||
const newDescription = ref("");
|
const newDescription = ref("");
|
||||||
const newGoal = ref("");
|
const newGoal = ref("");
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
|
// Step 2 of the modal (milestone 297): what the new project inherits.
|
||||||
|
const modalStep = ref<1 | 2>(1);
|
||||||
|
const newInception = ref<InceptionChoices>(emptyChoices());
|
||||||
|
|
||||||
const filteredProjects = computed(() => {
|
const filteredProjects = computed(() => {
|
||||||
if (activeTab.value === "all") return projects.value;
|
if (activeTab.value === "all") return projects.value;
|
||||||
@@ -73,6 +78,8 @@ function openNewProjectModal() {
|
|||||||
newTitle.value = "";
|
newTitle.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
newGoal.value = "";
|
newGoal.value = "";
|
||||||
|
modalStep.value = 1;
|
||||||
|
newInception.value = emptyChoices();
|
||||||
showNewProjectModal.value = true;
|
showNewProjectModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +95,15 @@ async function createProject() {
|
|||||||
title: newTitle.value.trim(),
|
title: newTitle.value.trim(),
|
||||||
description: newDescription.value.trim() || undefined,
|
description: newDescription.value.trim() || undefined,
|
||||||
goal: newGoal.value.trim() || undefined,
|
goal: newGoal.value.trim() || undefined,
|
||||||
|
// The decision rides the create: a project made here is never undecided.
|
||||||
|
inception: newInception.value,
|
||||||
});
|
});
|
||||||
projects.value.unshift(project);
|
projects.value.unshift(project);
|
||||||
showNewProjectModal.value = false;
|
showNewProjectModal.value = false;
|
||||||
toast.show("Project created");
|
toast.show("Project created");
|
||||||
router.push(`/projects/${project.id}`);
|
router.push(`/projects/${project.id}`);
|
||||||
} catch {
|
} catch (e: unknown) {
|
||||||
toast.show("Failed to create project", "error");
|
toast.show(apiErrorMessage(e, "Failed to create project"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
@@ -266,8 +275,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3 class="modal-title">New Project</h3>
|
<h3 class="modal-title">{{ modalStep === 1 ? "New Project" : "New Project — what it inherits" }}</h3>
|
||||||
<div class="modal-field">
|
<InceptionCard v-if="modalStep === 2" mode="create" v-model:choices="newInception" />
|
||||||
|
<div v-if="modalStep === 1" class="modal-field">
|
||||||
<label>Title <span class="required">*</span></label>
|
<label>Title <span class="required">*</span></label>
|
||||||
<input
|
<input
|
||||||
v-model="newTitle"
|
v-model="newTitle"
|
||||||
@@ -275,11 +285,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
class="modal-input"
|
class="modal-input"
|
||||||
placeholder="Project title"
|
placeholder="Project title"
|
||||||
autofocus
|
autofocus
|
||||||
@keydown.enter="createProject"
|
@keydown.enter="modalStep = 2"
|
||||||
@keydown.escape="closeModal"
|
@keydown.escape="closeModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-field">
|
<div v-if="modalStep === 1" class="modal-field">
|
||||||
<label>Goal</label>
|
<label>Goal</label>
|
||||||
<input
|
<input
|
||||||
v-model="newGoal"
|
v-model="newGoal"
|
||||||
@@ -289,7 +299,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
@keydown.escape="closeModal"
|
@keydown.escape="closeModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-field">
|
<div v-if="modalStep === 1" class="modal-field">
|
||||||
<label>Description</label>
|
<label>Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newDescription"
|
v-model="newDescription"
|
||||||
@@ -301,7 +311,17 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="modal-btn" @click="closeModal">Cancel</button>
|
<button class="modal-btn" @click="closeModal">Cancel</button>
|
||||||
|
<button v-if="modalStep === 2" class="modal-btn" @click="modalStep = 1">Back</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="modalStep === 1"
|
||||||
|
class="modal-btn modal-btn-primary"
|
||||||
|
@click="modalStep = 2"
|
||||||
|
:disabled="!newTitle.trim()"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
class="modal-btn modal-btn-primary"
|
class="modal-btn modal-btn-primary"
|
||||||
@click="createProject"
|
@click="createProject"
|
||||||
:disabled="!newTitle.trim() || creating"
|
:disabled="!newTitle.trim() || creating"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import ShareDialog from "@/components/ShareDialog.vue";
|
|||||||
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import SystemsSection from "@/components/SystemsSection.vue";
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
|
import InceptionCard from "@/components/InceptionCard.vue";
|
||||||
|
import { fmtDate } from "@/utils/dateFormat";
|
||||||
|
import type { InceptionDecision, InceptionRecord } from "@/api/inception";
|
||||||
import {
|
import {
|
||||||
fetchDesignSystems,
|
fetchDesignSystems,
|
||||||
setProjectDesignSystem,
|
setProjectDesignSystem,
|
||||||
@@ -50,6 +53,7 @@ interface Project {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
forge_connection_id: number | null;
|
forge_connection_id: number | null;
|
||||||
|
inception?: InceptionRecord | null;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -75,6 +79,12 @@ interface NoteItem {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
|
function onInceptionDecided(decision: InceptionDecision) {
|
||||||
|
if (project.value) project.value.inception = decision.inception;
|
||||||
|
toast.show("Inheritance recorded");
|
||||||
|
void loadProject();
|
||||||
|
}
|
||||||
const tasksStore = useTasksStore();
|
const tasksStore = useTasksStore();
|
||||||
|
|
||||||
const project = ref<Project | null>(null);
|
const project = ref<Project | null>(null);
|
||||||
@@ -695,6 +705,26 @@ async function confirmDelete() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Inception (milestone 297): the owner of an undecided project is asked
|
||||||
|
what it inherits; once recorded, one line says what was decided. -->
|
||||||
|
<InceptionCard
|
||||||
|
v-if="project.inception == null && isProjectOwner"
|
||||||
|
mode="decide"
|
||||||
|
:project-id="projectId"
|
||||||
|
@decided="onInceptionDecided"
|
||||||
|
/>
|
||||||
|
<p v-else-if="project.inception" class="inception-line">
|
||||||
|
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
||||||
|
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
|
||||||
|
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
|
||||||
|
</template>
|
||||||
|
<template v-if="project.inception.choices.subscribe_rulebooks.length">
|
||||||
|
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
||||||
|
</template>
|
||||||
|
· 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>
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Summary stat chips -->
|
<!-- Summary stat chips -->
|
||||||
<div v-if="project.summary" class="summary-stats">
|
<div v-if="project.summary" class="summary-stats">
|
||||||
<div class="stat-chip stat-todo">
|
<div class="stat-chip stat-todo">
|
||||||
@@ -1197,6 +1227,7 @@ async function confirmDelete() {
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inception-line { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.85rem; }
|
||||||
.project-title-input {
|
.project-title-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
|
|||||||
@@ -288,6 +288,32 @@ async def unsuppress_project_topic(project_id: int, topic_id: int):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.post("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
||||||
|
@login_required
|
||||||
|
async def exclude_project_rulebook(project_id: int, rulebook_id: int):
|
||||||
|
"""Opt the project out of a whole always-on rulebook (milestone 297)."""
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.exclude_always_on_rulebook_for_project(
|
||||||
|
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
return jsonify({"error": msg}), (400 if "not always-on" in msg else 404)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.delete("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
||||||
|
@login_required
|
||||||
|
async def include_project_rulebook(project_id: int, rulebook_id: int):
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.include_always_on_rulebook_for_project(
|
||||||
|
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||||
@login_required
|
@login_required
|
||||||
async def create_project_rule(project_id: int):
|
async def create_project_rule(project_id: int):
|
||||||
|
|||||||
@@ -57,3 +57,10 @@ async def test_session_context_names_the_excluded_always_on_rulebooks():
|
|||||||
assert lao.await_args.kwargs.get("project_id") == 9
|
assert lao.await_args.kwargs.get("project_id") == 9
|
||||||
assert "Excluded for this project by its inception decision" in out["context"]
|
assert "Excluded for this project by its inception decision" in out["context"]
|
||||||
assert "Design standards (#5)" in out["context"]
|
assert "Design standards (#5)" in out["context"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclusion_routes_are_registered():
|
||||||
|
from scribe.app import create_app
|
||||||
|
rules = {r.rule for r in create_app().url_map.iter_rules()}
|
||||||
|
assert "/api/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>" in rules
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user