feat: add description field to boards and update related components for onboarding
This commit is contained in:
@@ -175,6 +175,7 @@ async def start_onboarding(
|
||||
prompt = (
|
||||
"BOARD ONBOARDING REQUEST\n\n"
|
||||
f"Board Name: {board.name}\n"
|
||||
f"Board Description: {board.description or '(not provided)'}\n"
|
||||
"You are the gateway agent. Ask the user 6-10 focused questions total:\n"
|
||||
"- 3-6 questions to clarify the board goal.\n"
|
||||
"- 1 question to choose a unique name for the board lead agent "
|
||||
|
||||
@@ -23,6 +23,7 @@ class Board(TenantScoped, table=True):
|
||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
||||
name: str
|
||||
slug: str = Field(index=True)
|
||||
description: str = Field(default="")
|
||||
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)
|
||||
board_group_id: UUID | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlmodel import SQLModel
|
||||
|
||||
_ERR_GOAL_FIELDS_REQUIRED = "Confirmed goal boards require objective and success_metrics"
|
||||
_ERR_GATEWAY_REQUIRED = "gateway_id is required"
|
||||
_ERR_DESCRIPTION_REQUIRED = "description is required"
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
|
||||
|
||||
|
||||
@@ -19,6 +20,7 @@ class BoardBase(SQLModel):
|
||||
|
||||
name: str
|
||||
slug: str
|
||||
description: str
|
||||
gateway_id: UUID | None = None
|
||||
board_group_id: UUID | None = None
|
||||
board_type: str = "goal"
|
||||
@@ -37,6 +39,10 @@ class BoardCreate(BoardBase):
|
||||
@model_validator(mode="after")
|
||||
def validate_goal_fields(self) -> Self:
|
||||
"""Require gateway and goal details when creating a confirmed goal board."""
|
||||
description = self.description.strip()
|
||||
if not description:
|
||||
raise ValueError(_ERR_DESCRIPTION_REQUIRED)
|
||||
self.description = description
|
||||
if self.gateway_id is None:
|
||||
raise ValueError(_ERR_GATEWAY_REQUIRED)
|
||||
if (
|
||||
@@ -53,6 +59,7 @@ class BoardUpdate(SQLModel):
|
||||
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
description: str | None = None
|
||||
gateway_id: UUID | None = None
|
||||
board_group_id: UUID | None = None
|
||||
board_type: str | None = None
|
||||
@@ -68,6 +75,13 @@ class BoardUpdate(SQLModel):
|
||||
# Treat explicit null like "unset" is invalid for patch updates.
|
||||
if "gateway_id" in self.model_fields_set and self.gateway_id is None:
|
||||
raise ValueError(_ERR_GATEWAY_REQUIRED)
|
||||
if "description" in self.model_fields_set:
|
||||
if self.description is None:
|
||||
raise ValueError(_ERR_DESCRIPTION_REQUIRED)
|
||||
description = self.description.strip()
|
||||
if not description:
|
||||
raise ValueError(_ERR_DESCRIPTION_REQUIRED)
|
||||
self.description = description
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Add description field to boards.
|
||||
|
||||
Revision ID: c3b58a391f2e
|
||||
Revises: b308f2876359
|
||||
Create Date: 2026-02-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c3b58a391f2e"
|
||||
down_revision = "b308f2876359"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add required board description column."""
|
||||
op.add_column(
|
||||
"boards",
|
||||
sa.Column(
|
||||
"description",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
op.alter_column("boards", "description", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove board description column."""
|
||||
op.drop_column("boards", "description")
|
||||
@@ -6,7 +6,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from app.schemas.board_onboarding import BoardOnboardingConfirm
|
||||
from app.schemas.boards import BoardCreate
|
||||
from app.schemas.boards import BoardCreate, BoardUpdate
|
||||
|
||||
|
||||
def test_goal_board_requires_objective_and_metrics_when_confirmed() -> None:
|
||||
@@ -18,6 +18,7 @@ def test_goal_board_requires_objective_and_metrics_when_confirmed() -> None:
|
||||
BoardCreate(
|
||||
name="Goal Board",
|
||||
slug="goal",
|
||||
description="Ship onboarding improvements.",
|
||||
gateway_id=uuid4(),
|
||||
board_type="goal",
|
||||
goal_confirmed=True,
|
||||
@@ -26,6 +27,7 @@ def test_goal_board_requires_objective_and_metrics_when_confirmed() -> None:
|
||||
BoardCreate(
|
||||
name="Goal Board",
|
||||
slug="goal",
|
||||
description="Ship onboarding improvements.",
|
||||
gateway_id=uuid4(),
|
||||
board_type="goal",
|
||||
goal_confirmed=True,
|
||||
@@ -36,7 +38,13 @@ def test_goal_board_requires_objective_and_metrics_when_confirmed() -> None:
|
||||
|
||||
def test_goal_board_allows_missing_objective_before_confirmation() -> None:
|
||||
"""Draft goal boards may omit objective/success_metrics before confirmation."""
|
||||
BoardCreate(name="Draft", slug="draft", gateway_id=uuid4(), board_type="goal")
|
||||
BoardCreate(
|
||||
name="Draft",
|
||||
slug="draft",
|
||||
description="Iterate on backlog hygiene.",
|
||||
gateway_id=uuid4(),
|
||||
board_type="goal",
|
||||
)
|
||||
|
||||
|
||||
def test_general_board_allows_missing_objective() -> None:
|
||||
@@ -44,11 +52,30 @@ def test_general_board_allows_missing_objective() -> None:
|
||||
BoardCreate(
|
||||
name="General",
|
||||
slug="general",
|
||||
description="General coordination board.",
|
||||
gateway_id=uuid4(),
|
||||
board_type="general",
|
||||
)
|
||||
|
||||
|
||||
def test_board_create_requires_description() -> None:
|
||||
"""Board creation should reject empty descriptions."""
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
BoardCreate(
|
||||
name="Goal Board",
|
||||
slug="goal",
|
||||
description=" ",
|
||||
gateway_id=uuid4(),
|
||||
board_type="goal",
|
||||
)
|
||||
|
||||
|
||||
def test_board_update_rejects_empty_description_patch() -> None:
|
||||
"""Patch payloads should reject blank descriptions."""
|
||||
with pytest.raises(ValueError, match="description is required"):
|
||||
BoardUpdate(description=" ")
|
||||
|
||||
|
||||
def test_onboarding_confirm_requires_goal_fields() -> None:
|
||||
"""Onboarding confirm should enforce goal fields for goal board types."""
|
||||
with pytest.raises(
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { BoardCreateSuccessMetrics } from "./boardCreateSuccessMetrics";
|
||||
export interface BoardCreate {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
gateway_id?: string | null;
|
||||
board_group_id?: string | null;
|
||||
board_type?: string;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { BoardOnboardingAgentCompleteSuccessMetrics } from "./boardOnboardingAgentCompleteSuccessMetrics";
|
||||
import type { BoardOnboardingLeadAgentDraft } from "./boardOnboardingLeadAgentDraft";
|
||||
import type { BoardOnboardingUserProfile } from "./boardOnboardingUserProfile";
|
||||
import { BoardOnboardingAgentCompleteStatus } from "./boardOnboardingAgentCompleteStatus";
|
||||
|
||||
/**
|
||||
* Complete onboarding draft produced by the onboarding assistant.
|
||||
@@ -17,7 +16,7 @@ export interface BoardOnboardingAgentComplete {
|
||||
objective?: string | null;
|
||||
success_metrics?: BoardOnboardingAgentCompleteSuccessMetrics;
|
||||
target_date?: string | null;
|
||||
status: BoardOnboardingAgentCompleteStatus;
|
||||
status: "complete";
|
||||
user_profile?: BoardOnboardingUserProfile | null;
|
||||
lead_agent?: BoardOnboardingLeadAgentDraft | null;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { BoardReadSuccessMetrics } from "./boardReadSuccessMetrics";
|
||||
export interface BoardRead {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
gateway_id?: string | null;
|
||||
board_group_id?: string | null;
|
||||
board_type?: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { BoardUpdateSuccessMetrics } from "./boardUpdateSuccessMetrics";
|
||||
export interface BoardUpdate {
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
gateway_id?: string | null;
|
||||
board_group_id?: string | null;
|
||||
board_type?: string | null;
|
||||
|
||||
@@ -206,3 +206,4 @@ export * from "./updateAgentApiV1AgentsAgentIdPatchParams";
|
||||
export * from "./userRead";
|
||||
export * from "./userUpdate";
|
||||
export * from "./validationError";
|
||||
export * from "./validationErrorCtx";
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ValidationErrorCtx } from "./validationErrorCtx";
|
||||
|
||||
export interface ValidationError {
|
||||
loc: (string | number)[];
|
||||
msg: string;
|
||||
type: string;
|
||||
input?: unknown;
|
||||
ctx?: ValidationErrorCtx;
|
||||
}
|
||||
|
||||
8
frontend/src/api/generated/model/validationErrorCtx.ts
Normal file
8
frontend/src/api/generated/model/validationErrorCtx.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v8.2.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ValidationErrorCtx = { [key: string]: unknown };
|
||||
@@ -65,6 +65,7 @@ export default function EditBoardPage() {
|
||||
|
||||
const [board, setBoard] = useState<BoardRead | null>(null);
|
||||
const [name, setName] = useState<string | undefined>(undefined);
|
||||
const [description, setDescription] = useState<string | undefined>(undefined);
|
||||
const [gatewayId, setGatewayId] = useState<string | undefined>(undefined);
|
||||
const [boardGroupId, setBoardGroupId] = useState<string | undefined>(
|
||||
undefined,
|
||||
@@ -182,6 +183,7 @@ export default function EditBoardPage() {
|
||||
const baseBoard = board ?? loadedBoard;
|
||||
|
||||
const resolvedName = name ?? baseBoard?.name ?? "";
|
||||
const resolvedDescription = description ?? baseBoard?.description ?? "";
|
||||
const resolvedGatewayId = gatewayId ?? baseBoard?.gateway_id ?? "";
|
||||
const resolvedBoardGroupId =
|
||||
boardGroupId ?? baseBoard?.board_group_id ?? "none";
|
||||
@@ -209,7 +211,9 @@ export default function EditBoardPage() {
|
||||
boardQuery.error?.message ??
|
||||
null;
|
||||
|
||||
const isFormReady = Boolean(resolvedName.trim() && displayGatewayId);
|
||||
const isFormReady = Boolean(
|
||||
resolvedName.trim() && resolvedDescription.trim() && displayGatewayId,
|
||||
);
|
||||
|
||||
const gatewayOptions = useMemo(
|
||||
() =>
|
||||
@@ -231,6 +235,7 @@ export default function EditBoardPage() {
|
||||
|
||||
const handleOnboardingConfirmed = (updated: BoardRead) => {
|
||||
setBoard(updated);
|
||||
setDescription(updated.description ?? "");
|
||||
setBoardType(updated.board_type ?? "goal");
|
||||
setObjective(updated.objective ?? "");
|
||||
setSuccessMetrics(
|
||||
@@ -256,6 +261,11 @@ export default function EditBoardPage() {
|
||||
setError("Select a gateway before saving.");
|
||||
return;
|
||||
}
|
||||
const trimmedDescription = resolvedDescription.trim();
|
||||
if (!trimmedDescription) {
|
||||
setError("Board description is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setMetricsError(null);
|
||||
@@ -276,6 +286,7 @@ export default function EditBoardPage() {
|
||||
const payload: BoardUpdate = {
|
||||
name: trimmedName,
|
||||
slug: slugify(trimmedName),
|
||||
description: trimmedDescription,
|
||||
gateway_id: resolvedGatewayId || null,
|
||||
board_group_id:
|
||||
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
|
||||
@@ -410,6 +421,19 @@ export default function EditBoardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolvedDescription}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="What context should the lead agent know?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Objective
|
||||
|
||||
@@ -24,6 +24,7 @@ import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import SearchableSelect from "@/components/ui/searchable-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
@@ -39,6 +40,7 @@ export default function NewBoardPage() {
|
||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [gatewayId, setGatewayId] = useState<string>("");
|
||||
const [boardGroupId, setBoardGroupId] = useState<string>("none");
|
||||
|
||||
@@ -95,7 +97,9 @@ export default function NewBoardPage() {
|
||||
const errorMessage =
|
||||
error ?? gatewaysQuery.error?.message ?? groupsQuery.error?.message ?? null;
|
||||
|
||||
const isFormReady = Boolean(name.trim() && displayGatewayId);
|
||||
const isFormReady = Boolean(
|
||||
name.trim() && description.trim() && displayGatewayId,
|
||||
);
|
||||
|
||||
const gatewayOptions = useMemo(
|
||||
() =>
|
||||
@@ -124,6 +128,11 @@ export default function NewBoardPage() {
|
||||
setError("Select a gateway before creating a board.");
|
||||
return;
|
||||
}
|
||||
const trimmedDescription = description.trim();
|
||||
if (!trimmedDescription) {
|
||||
setError("Board description is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
@@ -131,6 +140,7 @@ export default function NewBoardPage() {
|
||||
data: {
|
||||
name: trimmedName,
|
||||
slug: slugify(trimmedName),
|
||||
description: trimmedDescription,
|
||||
gateway_id: resolvedGatewayId,
|
||||
board_group_id: boardGroupId === "none" ? null : boardGroupId,
|
||||
},
|
||||
@@ -208,6 +218,19 @@ export default function NewBoardPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="What context should the lead agent know before onboarding?"
|
||||
className="min-h-[120px]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
|
||||
@@ -37,6 +37,7 @@ const buildBoard = (overrides: Partial<BoardRead> = {}): BoardRead => ({
|
||||
id: "board-1",
|
||||
name: "Ops Board",
|
||||
slug: "ops-board",
|
||||
description: "Operations board context.",
|
||||
organization_id: "org-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
Reference in New Issue
Block a user