diff --git a/backend/app/api/v1/connections.py b/backend/app/api/v1/connections.py index 094a211..db205f1 100644 --- a/backend/app/api/v1/connections.py +++ b/backend/app/api/v1/connections.py @@ -5,11 +5,11 @@ from app.api.deps import get_current_workspace_id, get_optional_user from app.core.database import get_db -from app.schemas.connection import ConnectionCreate, ConnectionResponse, ConnectionUpdate from app.realtime.manager import ( fire_and_forget_publish, fire_and_forget_publish_diagram, ) +from app.schemas.connection import ConnectionCreate, ConnectionResponse, ConnectionUpdate from app.services import connection_service, diagram_service, object_service from app.services.webhook_service import fire_and_forget_emit @@ -84,8 +84,9 @@ async def create_connection( from_diagram_id=data.from_diagram_id, from_draft_id=data.from_draft_id, ) + body = ConnectionResponse.model_validate(conn).model_dump(mode="json") + await db.commit() if draft_id is None: - body = ConnectionResponse.model_validate(conn).model_dump(mode="json") fire_and_forget_emit("connection.created", body) fire_and_forget_publish( getattr(source, "workspace_id", None), @@ -115,8 +116,9 @@ async def update_connection( from_diagram_id=data.from_diagram_id, from_draft_id=data.from_draft_id, ) + body = ConnectionResponse.model_validate(conn).model_dump(mode="json") + await db.commit() if conn.draft_id is None: - body = ConnectionResponse.model_validate(conn).model_dump(mode="json") fire_and_forget_emit("connection.updated", body) src = await object_service.get_object(db, conn.source_id) fire_and_forget_publish( @@ -148,8 +150,9 @@ async def flip_connection( from_diagram_id=from_diagram_id, from_draft_id=from_draft_id, ) + body = ConnectionResponse.model_validate(conn).model_dump(mode="json") + await db.commit() if conn.draft_id is None: - body = ConnectionResponse.model_validate(conn).model_dump(mode="json") fire_and_forget_emit("connection.updated", body) src = await object_service.get_object(db, conn.source_id) fire_and_forget_publish( @@ -187,6 +190,7 @@ async def delete_connection( from_diagram_id=from_diagram_id, from_draft_id=from_draft_id, ) + await db.commit() if not was_draft: fire_and_forget_emit("connection.deleted", {"id": conn_id_str}) fire_and_forget_publish(src_ws_id, "connection.deleted", {"id": conn_id_str}) diff --git a/backend/app/api/v1/diagrams.py b/backend/app/api/v1/diagrams.py index afc05c7..855d3e6 100644 --- a/backend/app/api/v1/diagrams.py +++ b/backend/app/api/v1/diagrams.py @@ -4,7 +4,12 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_current_workspace_id, get_optional_user from app.core.database import get_db +from app.realtime.manager import ( + fire_and_forget_publish, + fire_and_forget_publish_diagram, +) from app.schemas.diagram import ( DiagramCreate, DiagramObjectCreate, @@ -13,13 +18,13 @@ DiagramResponse, DiagramUpdate, ) -from app.api.deps import get_current_workspace_id, get_optional_user -from app.realtime.manager import ( - fire_and_forget_publish, - fire_and_forget_publish_diagram, +from app.services import ( + access_service, + diagram_service, + draft_service, + pack_service, + workspace_service, ) -from app.services import access_service, diagram_service, draft_service, workspace_service -from app.services import pack_service from app.services.webhook_service import fire_and_forget_emit router = APIRouter(prefix="/diagrams", tags=["diagrams"]) @@ -177,14 +182,18 @@ async def add_object_to_diagram( diagram = await diagram_service.get_diagram(db, diagram_id) if not diagram: raise HTTPException(status_code=404, detail="Diagram not found") - obj = await diagram_service.add_object_to_diagram( - db, diagram_id, data, - actor_user=current_user, - workspace_id=getattr(diagram, "workspace_id", None), - from_draft_id=data.from_draft_id, - ) + try: + obj = await diagram_service.add_object_to_diagram( + db, diagram_id, data, + actor_user=current_user, + workspace_id=getattr(diagram, "workspace_id", None), + from_draft_id=data.from_draft_id, + ) + except diagram_service.DiagramObjectTargetMissingError as exc: + raise HTTPException(status_code=404, detail="Object not found") from exc body = DiagramObjectResponse.model_validate(obj).model_dump(mode="json") payload = {"diagram_id": str(diagram_id), "diagram_object": body} + await db.commit() fire_and_forget_publish( getattr(diagram, "workspace_id", None), "diagram_object.added", @@ -219,6 +228,7 @@ async def update_diagram_object( ) body = DiagramObjectResponse.model_validate(obj).model_dump(mode="json") payload = {"diagram_id": str(diagram_id), "diagram_object": body} + await db.commit() fire_and_forget_publish( getattr(diagram, "workspace_id", None) if diagram else None, "diagram_object.updated", @@ -249,6 +259,7 @@ async def remove_object_from_diagram( status_code=404, detail="Object not found in diagram" ) payload = {"diagram_id": str(diagram_id), "object_id": str(object_id)} + await db.commit() fire_and_forget_publish( getattr(diagram, "workspace_id", None) if diagram else None, "diagram_object.removed", diff --git a/backend/app/api/v1/objects.py b/backend/app/api/v1/objects.py index 3ed72e8..0fd78be 100644 --- a/backend/app/api/v1/objects.py +++ b/backend/app/api/v1/objects.py @@ -123,6 +123,7 @@ async def create_object( detail={"error": "invalid_repo_url", "message": str(exc)}, ) from exc response = ObjectResponse.from_model(obj) + await db.commit() if draft_id is None: body = response.model_dump(mode="json") fire_and_forget_emit("object.created", body) diff --git a/backend/app/services/diagram_service.py b/backend/app/services/diagram_service.py index b21766f..8338889 100644 --- a/backend/app/services/diagram_service.py +++ b/backend/app/services/diagram_service.py @@ -6,6 +6,7 @@ from app.models.connection import Connection from app.models.diagram import Diagram, DiagramObject +from app.models.object import ModelObject from app.models.technology import Technology from app.schemas.diagram import ( DiagramCreate, @@ -140,6 +141,11 @@ async def delete_diagram(db: AsyncSession, diagram: Diagram) -> None: # ─── Diagram Objects (positions) ────────────────────────── + +class DiagramObjectTargetMissingError(ValueError): + """The object being placed on a diagram does not exist.""" + + async def get_diagram_objects( db: AsyncSession, diagram_id: uuid.UUID ) -> list[DiagramObject]: @@ -170,6 +176,10 @@ async def add_object_to_diagram( workspace_id: uuid.UUID | None = None, from_draft_id: uuid.UUID | None = None, ) -> DiagramObject: + target = await db.get(ModelObject, data.object_id) + if target is None: + raise DiagramObjectTargetMissingError(str(data.object_id)) + obj = DiagramObject( diagram_id=diagram_id, object_id=data.object_id, @@ -199,7 +209,7 @@ async def add_object_to_diagram( target_type=UndoTargetType.DIAGRAM_OBJECT, target_id=obj.id, action=UndoAction.CREATE, - forward_summary=f"Added object to diagram"[:80], + forward_summary="Added object to diagram"[:80], inverse_payload={"target_id": str(obj.id)}, after_state=activity_service.snapshot(obj, include_metadata=True), coalesce_key=f"diagram_object:{obj.id}:create", @@ -263,7 +273,7 @@ async def update_diagram_object( target_type=UndoTargetType.DIAGRAM_OBJECT, target_id=obj.id, action=UndoAction.UPDATE, - forward_summary=f"Moved object in diagram"[:80], + forward_summary="Moved object in diagram"[:80], inverse_payload={"before": {k: v["before"] for k, v in pos_diff.items()}}, after_state={k: v["after"] for k, v in pos_diff.items()}, coalesce_key=f"diagram_object:{obj.id}:position", @@ -280,7 +290,7 @@ async def update_diagram_object( target_type=UndoTargetType.DIAGRAM_OBJECT, target_id=obj.id, action=UndoAction.UPDATE, - forward_summary=f"Resized object in diagram"[:80], + forward_summary="Resized object in diagram"[:80], inverse_payload={"before": {k: v["before"] for k, v in size_diff.items()}}, after_state={k: v["after"] for k, v in size_diff.items()}, coalesce_key=f"diagram_object:{obj.id}:size", @@ -298,7 +308,7 @@ async def update_diagram_object( target_type=UndoTargetType.DIAGRAM_OBJECT, target_id=obj.id, action=UndoAction.UPDATE, - forward_summary=f"Updated diagram object"[:80], + forward_summary="Updated diagram object"[:80], inverse_payload={"before": {k: v["before"] for k, v in other_diff.items()}}, after_state={k: v["after"] for k, v in other_diff.items()}, coalesce_key=f"diagram_object:{obj.id}:{','.join(sorted(other_diff.keys()))}", @@ -351,7 +361,7 @@ async def remove_object_from_diagram( target_type=UndoTargetType.DIAGRAM_OBJECT, target_id=do_id, action=UndoAction.DELETE, - forward_summary=f"Removed object from diagram"[:80], + forward_summary="Removed object from diagram"[:80], inverse_payload={"snapshot": snapshot, "id": str(do_id)}, after_state=None, coalesce_key=f"diagram_object:{do_id}:delete", diff --git a/backend/tests/api/test_object_create_visibility.py b/backend/tests/api/test_object_create_visibility.py new file mode 100644 index 0000000..0e5ec08 --- /dev/null +++ b/backend/tests/api/test_object_create_visibility.py @@ -0,0 +1,205 @@ +import uuid + +import pytest +from fastapi import HTTPException +from sqlalchemy import delete, select + +from app.api.v1.connections import ( + create_connection as create_connection_endpoint, +) +from app.api.v1.connections import ( + delete_connection as delete_connection_endpoint, +) +from app.api.v1.diagrams import add_object_to_diagram, remove_object_from_diagram +from app.api.v1.objects import create_object as create_object_endpoint +from app.core.database import async_session +from app.models.activity_log import ActivityLog, ActivityTargetType +from app.models.connection import Connection +from app.models.diagram import DiagramObject +from app.models.object import ModelObject, ObjectType +from app.schemas.connection import ConnectionCreate +from app.schemas.diagram import DiagramObjectCreate +from app.schemas.object import ObjectCreate + + +@pytest.mark.asyncio +async def test_create_object_commits_row_and_activity_before_return(db): + name = f"Race visible {uuid.uuid4().hex}" + response = await create_object_endpoint( + ObjectCreate(name=name, type="system"), + draft_id=None, + db=db, + current_user=None, + x_workspace_id=None, + ) + + try: + async with async_session() as other: + obj = await other.get(ModelObject, response.id) + assert obj is not None + assert obj.name == name + + activity = ( + await other.execute( + select(ActivityLog).where( + ActivityLog.target_type == ActivityTargetType.OBJECT, + ActivityLog.target_id == response.id, + ) + ) + ).scalar_one_or_none() + assert activity is not None + finally: + async with async_session() as cleanup: + await cleanup.execute( + delete(ActivityLog).where(ActivityLog.target_id == response.id) + ) + await cleanup.execute(delete(ModelObject).where(ModelObject.id == response.id)) + await cleanup.commit() + + +@pytest.mark.asyncio +async def test_add_object_to_diagram_returns_404_for_missing_object(db, diagram): + with pytest.raises(HTTPException) as exc_info: + await add_object_to_diagram( + diagram.id, + DiagramObjectCreate(object_id=uuid.uuid4()), + db=db, + current_user=None, + workspace_id=None, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Object not found" + + +@pytest.mark.asyncio +async def test_add_object_to_diagram_commits_before_return(db, diagram, workspace): + obj = ModelObject( + name=f"Placed {uuid.uuid4().hex}", + type=ObjectType.SYSTEM, + workspace_id=workspace.id, + ) + db.add(obj) + await db.flush() + + response = await add_object_to_diagram( + diagram.id, + DiagramObjectCreate(object_id=obj.id, position_x=10, position_y=20), + db=db, + current_user=None, + workspace_id=None, + ) + + try: + async with async_session() as other: + placement = await other.get(DiagramObject, response.id) + assert placement is not None + assert placement.object_id == obj.id + finally: + async with async_session() as cleanup: + await cleanup.execute(delete(ModelObject).where(ModelObject.id == obj.id)) + await cleanup.commit() + + +@pytest.mark.asyncio +async def test_remove_object_from_diagram_commits_before_return(db, diagram, workspace): + obj = ModelObject( + name=f"Removed {uuid.uuid4().hex}", + type=ObjectType.SYSTEM, + workspace_id=workspace.id, + ) + db.add(obj) + await db.flush() + placement = DiagramObject(diagram_id=diagram.id, object_id=obj.id) + db.add(placement) + await db.commit() + + await remove_object_from_diagram( + diagram.id, + obj.id, + from_draft_id=None, + db=db, + current_user=None, + workspace_id=None, + ) + + try: + async with async_session() as other: + assert await other.get(DiagramObject, placement.id) is None + finally: + async with async_session() as cleanup: + await cleanup.execute(delete(ModelObject).where(ModelObject.id == obj.id)) + await cleanup.commit() + + +@pytest.mark.asyncio +async def test_create_connection_commits_before_return(db, workspace): + source = ModelObject( + name=f"Source {uuid.uuid4().hex}", + type=ObjectType.SYSTEM, + workspace_id=workspace.id, + ) + target = ModelObject( + name=f"Target {uuid.uuid4().hex}", + type=ObjectType.SYSTEM, + workspace_id=workspace.id, + ) + db.add_all([source, target]) + await db.flush() + + response = await create_connection_endpoint( + ConnectionCreate(source_id=source.id, target_id=target.id), + draft_id=None, + db=db, + current_user=None, + ) + + try: + async with async_session() as other: + conn = await other.get(Connection, response.id) + assert conn is not None + assert conn.source_id == source.id + assert conn.target_id == target.id + finally: + async with async_session() as cleanup: + await cleanup.execute( + delete(ModelObject).where(ModelObject.id.in_([source.id, target.id])) + ) + await cleanup.commit() + + +@pytest.mark.asyncio +async def test_delete_connection_commits_before_return(db, workspace): + source = ModelObject( + name=f"Source {uuid.uuid4().hex}", + type=ObjectType.SYSTEM, + workspace_id=workspace.id, + ) + target = ModelObject( + name=f"Target {uuid.uuid4().hex}", + type=ObjectType.SYSTEM, + workspace_id=workspace.id, + ) + db.add_all([source, target]) + await db.flush() + conn = Connection(source_id=source.id, target_id=target.id) + db.add(conn) + await db.commit() + + await delete_connection_endpoint( + conn.id, + from_diagram_id=None, + from_draft_id=None, + db=db, + current_user=None, + ) + + try: + async with async_session() as other: + assert await other.get(Connection, conn.id) is None + finally: + async with async_session() as cleanup: + await cleanup.execute( + delete(ModelObject).where(ModelObject.id.in_([source.id, target.id])) + ) + await cleanup.commit() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fbb08cf..4c4658d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,6 @@ +import json import uuid +from pathlib import Path import pytest from httpx import ASGITransport, AsyncClient @@ -50,6 +52,65 @@ async def client(): "users", ) +_TECHNOLOGY_SEED_PATH = Path(__file__).resolve().parents[1] / "data" / "technologies.json" +_EXTRA_BUILTIN_PROTOCOLS = ( + { + "slug": "mcp", + "name": "MCP", + "iconify_name": "mdi:message-processing-outline", + "category": "protocol", + "color": "#D97757", + "aliases": ["model-context-protocol", "model context protocol"], + }, + { + "slug": "a2a", + "name": "A2A", + "iconify_name": "mdi:account-switch-outline", + "category": "protocol", + "color": "#6366F1", + "aliases": ["agent-to-agent", "agent to agent"], + }, +) + + +async def _restore_builtin_technologies(session): + """Restore built-in technology rows after workspace truncation. + + TRUNCATE workspaces CASCADE removes workspace-scoped technologies and, in + PostgreSQL, also truncates the whole referencing technologies table. The + migration seed does not rerun between tests, so put the built-ins back. + """ + rows = json.loads(_TECHNOLOGY_SEED_PATH.read_text()) + list(_EXTRA_BUILTIN_PROTOCOLS) + insert_sql = text( + """ + INSERT INTO technologies + (id, workspace_id, slug, name, iconify_name, category, color, aliases) + VALUES + (gen_random_uuid(), NULL, :slug, :name, :iconify_name, + CAST(:category AS tech_category), :color, :aliases) + ON CONFLICT (slug) WHERE workspace_id IS NULL + DO UPDATE SET + name = EXCLUDED.name, + iconify_name = EXCLUDED.iconify_name, + category = EXCLUDED.category, + color = EXCLUDED.color, + aliases = EXCLUDED.aliases, + updated_at = now() + """ + ) + for row in rows: + await session.execute( + insert_sql, + { + "slug": row["slug"], + "name": row["name"], + "iconify_name": row["iconify_name"], + "category": row["category"].upper(), + "color": row.get("color"), + "aliases": row.get("aliases") or None, + }, + ) + @pytest.fixture async def db(): @@ -63,6 +124,7 @@ async def db(): + " RESTART IDENTITY CASCADE" ) ) + await _restore_builtin_technologies(session) await session.commit() try: yield session diff --git a/frontend/src/components/canvas/AddObjectFAB.tsx b/frontend/src/components/canvas/AddObjectFAB.tsx index 2456435..c4e52bf 100644 --- a/frontend/src/components/canvas/AddObjectFAB.tsx +++ b/frontend/src/components/canvas/AddObjectFAB.tsx @@ -12,26 +12,18 @@ import { import { useDiagram } from '../../hooks/use-diagrams' import { useCanvasStore } from '../../stores/canvas-store' import { useWorkspaceStore } from '../../stores/workspace-store' -import type { CommentType, DiagramType, ObjectType } from '../../types/model' +import { C4_DIAGRAM_LEVEL_LABELS, type CommentType, type DiagramType, type ObjectType } from '../../types/model' import { cn } from '../../utils/cn' import { SectionLabel } from '../ui' import { TechIcon } from '../tech' import { detectParentGroup, nodeToRect } from './group-utils' -import { TYPE_BORDER_COLORS, TYPE_LABELS } from './node-utils' +import { getObjectTypeLabel, TYPE_BORDER_COLORS } from './node-utils' import { NewObjectModal } from './NewObjectModal' // ─── Type helpers (match AddObjectToolbar's logic exactly) ──────────────────── const ALL_QUICK_TYPES: ObjectType[] = ['system', 'actor', 'external_system', 'app', 'store', 'component', 'group'] -const DIAGRAM_LEVEL_LABEL: Record = { - system_landscape: 'L1 · System Landscape', - system_context: 'L1 · System Context', - container: 'L2 · Container', - component: 'L3 · Component', - custom: 'L4 · Code', -} - function getQuickTypesForDiagram(diagramType: DiagramType | undefined): ObjectType[] { if (!diagramType) return ALL_QUICK_TYPES switch (diagramType) { @@ -43,6 +35,9 @@ function getQuickTypesForDiagram(diagramType: DiagramType | undefined): ObjectTy case 'component': return ['component', 'system', 'external_system', 'actor', 'group'] case 'custom': + // C4 L4 is the Code diagram. The backend reuses the `component` object + // type for code-level elements, so label it as Code in this context. + return ['component', 'group'] default: return ALL_QUICK_TYPES } @@ -262,7 +257,7 @@ export function AddObjectFAB({ diagramId }: AddObjectFABProps) { const draftId = diagram?.draft_id ?? null const diagramType = diagram?.type as DiagramType | undefined const quickTypes = getQuickTypesForDiagram(diagramType) - const levelLabel = diagramType ? DIAGRAM_LEVEL_LABEL[diagramType] : null + const levelLabel = diagramType ? C4_DIAGRAM_LEVEL_LABELS[diagramType] : null const { data: objects = [] } = useObjects(draftId) const { data: diagramObjects = [] } = useDiagramObjects(diagramId) @@ -372,19 +367,31 @@ export function AddObjectFAB({ diagramId }: AddObjectFABProps) { const popupRef = useRef(null) const fabRef = useRef(null) - // Popup anchors its horizontal edge to the FAB's right side but floats to - // the viewport's vertical centre — so it never clips off the screen - // regardless of where the FAB itself sits within the canvas. - const [popupLeft, setPopupLeft] = useState(72) + // Popup anchors to the FAB but is clamped into the visible viewport. + // DevTools/narrow split panes can leave far less room than the full app + // normally has, so both the horizontal edge and vertical bounds are + // computed from the current viewport instead of using fixed 72px insets. + const [popupMetrics, setPopupMetrics] = useState({ left: 72, top: 60, bottom: 12, width: 340 }) useLayoutEffect(() => { if (!isOpen) return const recompute = () => { const rect = fabRef.current?.getBoundingClientRect() - if (rect) setPopupLeft(rect.right + 12) + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const desiredWidth = Math.min(340, Math.max(260, viewportWidth - 24)) + const preferredLeft = rect ? rect.right + 12 : 72 + const left = Math.min(Math.max(12, preferredLeft), Math.max(12, viewportWidth - desiredWidth - 12)) + const top = Math.min(60, Math.max(12, viewportHeight - 280)) + const bottom = 12 + setPopupMetrics({ left, top, bottom, width: desiredWidth }) } recompute() window.addEventListener('resize', recompute) - return () => window.removeEventListener('resize', recompute) + window.visualViewport?.addEventListener('resize', recompute) + return () => { + window.removeEventListener('resize', recompute) + window.visualViewport?.removeEventListener('resize', recompute) + } }, [isOpen]) useEffect(() => { @@ -468,22 +475,22 @@ export function AddObjectFAB({ diagramId }: AddObjectFABProps) { className="add-popup flex flex-col" style={{ position: 'fixed', - left: popupLeft, - // Fill the canvas area vertically: top ~ just below the 48px - // top-bar + a little breathing room; bottom ~ just above the - // bottom tags-bar. Popup auto-stretches so the object pool gets - // maximum height while Create / Annotation sections stay pinned - // to the bottom of the popup via flex layout. - top: 72, - bottom: 72, - width: 340, + left: popupMetrics.left, + // Keep the portal inside the visible viewport even when DevTools + // leaves only a short canvas. The lower inset is intentionally + // small so the object pool keeps usable scroll height instead of + // collapsing behind the create/comment sections. + top: popupMetrics.top, + bottom: popupMetrics.bottom, + width: popupMetrics.width, background: 'var(--color-panel)', border: '1px solid var(--color-border-base)', borderRadius: 12, boxShadow: '0 20px 60px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.02)', zIndex: 60, - overflow: 'hidden', + overflowX: 'hidden', + overflowY: 'auto', }} > {/* ── Fixed header: search ── */} @@ -524,7 +531,7 @@ export function AddObjectFAB({ diagramId }: AddObjectFABProps) { Create + Annotation sections below stay pinned. ── */}
- {TYPE_LABELS[obj.type].toLowerCase()} + {getObjectTypeLabel(obj.type, diagramType).toLowerCase()} {(() => { const techs = (obj.technology_ids ?? []) .map((id) => catalog.find((t) => t.id === id)) @@ -657,7 +664,7 @@ export function AddObjectFAB({ diagramId }: AddObjectFABProps) {
- {TYPE_LABELS[type]} + {getObjectTypeLabel(type, diagramType)}
))} @@ -710,7 +717,7 @@ export function AddObjectFAB({ diagramId }: AddObjectFABProps) { {/* ── Fixed footer ── */}
Click canvas to place diff --git a/frontend/src/components/canvas/ArchFlowCanvas.test.tsx b/frontend/src/components/canvas/ArchFlowCanvas.test.tsx index 3f44e08..c7f9406 100644 --- a/frontend/src/components/canvas/ArchFlowCanvas.test.tsx +++ b/frontend/src/components/canvas/ArchFlowCanvas.test.tsx @@ -8,6 +8,12 @@ const h = vi.hoisted(() => ({ commentComposeType: null as null | string, dependenciesFocusId: null as null | string, allObjects: [] as Array<{ id: string; name: string; type: string }>, + diagramObjects: [] as Array>, + connections: [] as Array>, + currentNodes: [] as Array<{ id: string; data?: unknown }>, + currentEdges: [] as Array<{ id: string; source?: string; target?: string; data?: unknown }>, + setNodes: vi.fn(), + setEdges: vi.fn(), })) vi.mock('@xyflow/react', () => ({ @@ -27,21 +33,21 @@ vi.mock('@xyflow/react', () => ({ ConnectionMode: { Loose: 'Loose' }, MarkerType: { ArrowClosed: 'ArrowClosed' }, useReactFlow: () => ({ - setNodes: vi.fn(), - setEdges: vi.fn(), - getNodes: () => [], - getEdges: () => [], + setNodes: h.setNodes, + setEdges: h.setEdges, + getNodes: () => h.currentNodes, + getEdges: () => h.currentEdges, screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ x, y }), fitView: vi.fn(), }), })) vi.mock('../../hooks/use-api', () => ({ - useConnections: () => ({ data: [] }), + useConnections: () => ({ data: h.connections }), useCreateComment: () => ({ mutate: vi.fn() }), useCreateConnection: () => ({ mutate: vi.fn() }), useDeleteConnection: () => ({ mutate: vi.fn() }), - useDiagramObjects: () => ({ data: [] }), + useDiagramObjects: () => ({ data: h.diagramObjects }), useFlows: () => ({ data: [] }), useObjects: () => ({ data: h.allObjects }), useRemoveObjectFromDiagram: () => ({ mutate: vi.fn() }), @@ -115,6 +121,12 @@ describe('ArchFlowCanvas theming', () => { h.commentComposeType = null h.dependenciesFocusId = null h.allObjects = [] + h.diagramObjects = [] + h.connections = [] + h.currentNodes = [] + h.currentEdges = [] + h.setNodes.mockClear() + h.setEdges.mockClear() }) it('uses semantic theme variables for the canvas, grid, and minimap mask', () => { @@ -142,4 +154,24 @@ describe('ArchFlowCanvas theming', () => { expect(dependencyNotice?.getAttribute('style')).toContain('border: 1px solid var(--color-accent-blue)') expect(dependencyNotice?.getAttribute('style')).toContain('color: var(--color-text-base)') }) + + it('prunes stale ReactFlow nodes when objects or placements disappear', () => { + h.currentNodes = [ + { id: 'deleted-object', data: { object: { id: 'deleted-object', name: 'Deleted', type: 'system' } } }, + ] + + render() + + expect(h.setNodes).toHaveBeenCalledWith([]) + }) + + it('prunes stale ReactFlow edges when endpoint placements disappear', () => { + h.currentEdges = [ + { id: 'stale:directed:a:b', source: 'a', target: 'b', data: { connId: 'stale' } }, + ] + + render() + + expect(h.setEdges).toHaveBeenCalledWith([]) + }) }) diff --git a/frontend/src/components/canvas/ArchFlowCanvas.tsx b/frontend/src/components/canvas/ArchFlowCanvas.tsx index 9b10608..fbed270 100644 --- a/frontend/src/components/canvas/ArchFlowCanvas.tsx +++ b/frontend/src/components/canvas/ArchFlowCanvas.tsx @@ -254,8 +254,8 @@ function CanvasInner({ diagramId }: ArchFlowCanvasProps) { [screenToFlowPosition, sendCursor], ) - const prevKeyRef = useRef('') - const prevConnsRef = useRef('') + const prevKeyRef = useRef(null) + const prevConnsRef = useRef(null) // Stores {groupId -> {nodeId -> startPosition}} while a group drag is in progress. const groupDragStartRef = useRef | null>(null) @@ -302,7 +302,10 @@ function CanvasInner({ diagramId }: ArchFlowCanvasProps) { ? 'external' : 'c4', position: { x: dObj.position_x, y: dObj.position_y }, - data: { object: obj } satisfies C4NodeData, + data: { + object: obj, + diagramType: diagram?.type as C4NodeData['diagramType'], + } satisfies C4NodeData, zIndex: obj.type === 'group' ? 0 : 1, } // Restore persisted node size from the diagram_objects row so the @@ -329,9 +332,6 @@ function CanvasInner({ diagramId }: ArchFlowCanvasProps) { return `${n.id}:${n.position.x}:${n.position.y}:${n.width ?? ''}:${n.height ?? ''}:${obj.updated_at}:${n.type}` }) .join(',') - if (key === prevKeyRef.current) return - prevKeyRef.current = key - // The diagram-objects cache is authoritative for position + size. // Carry over selection / overlay styling from the previous nodes, and // ONLY preserve local state while a drag or resize is in progress (so @@ -339,6 +339,12 @@ function CanvasInner({ diagramId }: ArchFlowCanvasProps) { // user). Anything else — including another collaborator's drag — we // let through so two-browser edits actually propagate. const currentNodes = getNodes() + const nodeIds = new Set(nodes.map((n) => n.id)) + const currentNodesMatch = + currentNodes.length === nodes.length && currentNodes.every((n) => nodeIds.has(n.id)) + if (key === prevKeyRef.current && currentNodesMatch) return + prevKeyRef.current = key + const merged = nodes.map((n) => { const existing = currentNodes.find((cn) => cn.id === n.id) const obj = (n.data as C4NodeData).object @@ -366,6 +372,7 @@ function CanvasInner({ diagramId }: ArchFlowCanvasProps) { diagramId, allObjects, diagramObjects, + diagram?.type, setNodes, getNodes, dependencyChain, @@ -411,14 +418,10 @@ function CanvasInner({ diagramId }: ArchFlowCanvasProps) { const overlayKey = `${dependencyChain ? JSON.stringify([...dependencyChain.edges]) : ''}|${filterDim}|${flowPlayback?.currentConnId ?? ''}|${flowPlayback ? [...flowPlayback.stepNumbers.entries()].map(([k,v]) => k+':'+v).join(',') : ''}` const combinedKey = connKey + '||' + overlayKey - if (combinedKey === prevConnsRef.current) return - prevConnsRef.current = combinedKey - // Preserve selection state across re-renders + apply overlay opacity + // flow playback step number/highlight. const currentEdges = getEdges() - setEdges( - filtered.map(connectionToEdge).map((e) => { + const nextEdges = filtered.map(connectionToEdge).map((e) => { const connId = (e.data as { connId: string }).connId // Match by connId, not edge id — when direction changes the fingerprinted // id differs but we still want to preserve the `selected` state. @@ -443,8 +446,16 @@ function CanvasInner({ diagramId }: ArchFlowCanvasProps) { flowCurrent: isCurrent, }, } - return existing?.selected ? { ...withStyle, selected: true } : withStyle - }), + return existing?.selected ? { ...withStyle, selected: true } : withStyle + }) + const nextEdgeIds = new Set(nextEdges.map((e) => e.id)) + const currentEdgesMatch = + currentEdges.length === nextEdges.length && currentEdges.every((e) => nextEdgeIds.has(e.id)) + if (combinedKey === prevConnsRef.current && currentEdgesMatch) return + prevConnsRef.current = combinedKey + + setEdges( + nextEdges, ) }, [connections, diagramObjects, setEdges, getEdges, dependencyChain, flowPlayback, filterDim]) diff --git a/frontend/src/components/canvas/C4Node.tsx b/frontend/src/components/canvas/C4Node.tsx index 6905911..5df9cea 100644 --- a/frontend/src/components/canvas/C4Node.tsx +++ b/frontend/src/components/canvas/C4Node.tsx @@ -1,7 +1,7 @@ import { Handle, NodeResizer, Position, useNodeId, type NodeProps } from '@xyflow/react' import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import type { ModelObject } from '../../types/model' +import type { DiagramType, ModelObject } from '../../types/model' import { useSaveDiagramSize, useTechnologies } from '../../hooks/use-api' import { useDiagrams } from '../../hooks/use-diagrams' import { useCanvasStore } from '../../stores/canvas-store' @@ -13,10 +13,11 @@ import { DRILLABLE_TYPES, defaultChildDiagramName, } from '../drafts/CreateChildDiagramModal' -import { STATUS_COLORS, TYPE_ICONS, stripHtml } from './node-utils' +import { getObjectTypeLabel, STATUS_COLORS, TYPE_ICONS, stripHtml } from './node-utils' export type C4NodeData = { object: ModelObject + diagramType?: DiagramType } function drillTooltip(objectName: string, objectType: string): string { @@ -58,18 +59,9 @@ const TYPE_DOT_COLOR: Record = { external_system: 'var(--color-text-3)', } -const TYPE_PILL_LABEL: Record = { - system: 'SYSTEM', - app: 'CONTAINER', - store: 'CONTAINER', - component: 'COMPONENT', - group: 'GROUP', - actor: 'ACTOR', - external_system: 'EXTERNAL', -} - export function C4Node({ data, selected }: NodeProps) { const obj = (data as C4NodeData).object + const diagramType = (data as C4NodeData).diagramType const statusColor = STATUS_COLORS[obj.status] const navigate = useNavigate() const params = useParams<{ diagramId?: string }>() @@ -84,7 +76,7 @@ export function C4Node({ data, selected }: NodeProps) { (s) => (nodeId ? s.remoteNodeEditors[nodeId] : undefined), ) - const canHaveChildren = DRILLABLE_TYPES.has(obj.type) + const canHaveChildren = DRILLABLE_TYPES.has(obj.type) && diagramType !== 'custom' const handleDrillDown = (e: React.MouseEvent) => { e.stopPropagation() @@ -110,7 +102,7 @@ export function C4Node({ data, selected }: NodeProps) { : `Zoom into (${childDiagrams.length} diagram${childDiagrams.length > 1 ? 's' : ''})` const typeDotColor = TYPE_DOT_COLOR[obj.type] ?? 'var(--color-text-3)' - const typeLabel = TYPE_PILL_LABEL[obj.type] ?? obj.type.toUpperCase() + const typeLabel = getObjectTypeLabel(obj.type, diagramType).toUpperCase() const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId) // React Query dedupes across every node, so rendering N nodes triggers a // single network round-trip for the catalog; staleTime keeps it in cache. diff --git a/frontend/src/components/canvas/node-utils.test.ts b/frontend/src/components/canvas/node-utils.test.ts new file mode 100644 index 0000000..a3b7ea2 --- /dev/null +++ b/frontend/src/components/canvas/node-utils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { C4_DIAGRAM_LEVEL_LABELS } from '../../types/model' +import { getObjectTypeLabel } from './node-utils' + +describe('C4 diagram and object labels', () => { + it('uses official L1-L4 labels and keeps landscape separate from L1 wording', () => { + expect(C4_DIAGRAM_LEVEL_LABELS.system_landscape).toBe('Landscape') + expect(C4_DIAGRAM_LEVEL_LABELS.system_context).toBe('L1 · System Context') + expect(C4_DIAGRAM_LEVEL_LABELS.container).toBe('L2 · Container') + expect(C4_DIAGRAM_LEVEL_LABELS.component).toBe('L3 · Component') + expect(C4_DIAGRAM_LEVEL_LABELS.custom).toBe('L4 · Code') + }) + + it('labels component objects as code inside L4 code diagrams only', () => { + expect(getObjectTypeLabel('component', 'component')).toBe('Component') + expect(getObjectTypeLabel('component', 'custom')).toBe('Code') + }) +}) diff --git a/frontend/src/components/canvas/node-utils.ts b/frontend/src/components/canvas/node-utils.ts index 59a8ed8..dba6256 100644 --- a/frontend/src/components/canvas/node-utils.ts +++ b/frontend/src/components/canvas/node-utils.ts @@ -1,4 +1,4 @@ -import type { ObjectStatus, ObjectType } from '../../types/model' +import type { DiagramType, ObjectStatus, ObjectType } from '../../types/model' export const TYPE_ICONS: Record = { system: '■', @@ -20,6 +20,11 @@ export const TYPE_LABELS: Record = { component: 'Component', } +export function getObjectTypeLabel(type: ObjectType, diagramType?: DiagramType): string { + if (type === 'component' && diagramType === 'custom') return 'Code' + return TYPE_LABELS[type] +} + export const STATUS_COLORS: Record = { live: '#22c55e', future: '#a855f7', diff --git a/frontend/src/components/common/RichTextEditor.tsx b/frontend/src/components/common/RichTextEditor.tsx index 5f4211b..a1fdec1 100644 --- a/frontend/src/components/common/RichTextEditor.tsx +++ b/frontend/src/components/common/RichTextEditor.tsx @@ -2,6 +2,7 @@ import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Placeholder from '@tiptap/extension-placeholder' import { useEffect } from 'react' +import { cn } from '../../utils/cn' interface RichTextEditorProps { content: string @@ -21,8 +22,7 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito }, editorProps: { attributes: { - style: - 'min-height: 60px; outline: none; font-size: 13px; color: #e5e5e5; line-height: 1.5;', + class: 'rich-text-editor__content', }, }, }) @@ -37,11 +37,9 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito if (!editor) return null return ( -
+
{/* Toolbar */} -
+
editor.chain().focus().toggleBold().run()} @@ -67,7 +65,7 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
{/* Editor */} -
+
@@ -88,17 +86,9 @@ function ToolBtn({ return ( diff --git a/frontend/src/components/diagram/NewDiagramModal.tsx b/frontend/src/components/diagram/NewDiagramModal.tsx index 382b7f7..01a47dc 100644 --- a/frontend/src/components/diagram/NewDiagramModal.tsx +++ b/frontend/src/components/diagram/NewDiagramModal.tsx @@ -5,7 +5,7 @@ import { useCreateDiagram, type Diagram } from '../../hooks/use-diagrams' import { usePacks } from '../../hooks/use-api' import { useWorkspaceStore } from '../../stores/workspace-store' import { cn } from '../../utils/cn' -import type { DiagramType } from '../../types/model' +import { C4_DIAGRAM_LABELS, type DiagramType } from '../../types/model' // ─── Props ──────────────────────────────────────────────────────────────────── @@ -35,7 +35,7 @@ const TYPE_META: TypeMeta[] = [ { value: 'system_landscape', label: 'System Landscape', - levelTag: 'L1', + levelTag: 'LANDSCAPE', description: 'High-level system view', color: '#c084fc', bgGlow: 'rgba(192,132,252,0.12)', @@ -94,9 +94,9 @@ const TYPE_META: TypeMeta[] = [ }, { value: 'custom', - label: 'Custom', - levelTag: '—', - description: 'Custom diagram', + label: C4_DIAGRAM_LABELS.custom, + levelTag: 'L4', + description: 'Code-level view inside a component', color: '#4ade80', bgGlow: 'rgba(74,222,128,0.12)', borderActive: '#4ade80', diff --git a/frontend/src/components/sidebar/ObjectSidebar.tsx b/frontend/src/components/sidebar/ObjectSidebar.tsx index 8b9d72f..1885793 100644 --- a/frontend/src/components/sidebar/ObjectSidebar.tsx +++ b/frontend/src/components/sidebar/ObjectSidebar.tsx @@ -10,10 +10,10 @@ import { useUpdateObject, type ActivityLogEntry, } from '../../hooks/use-api' -import { useDiagrams, useObjectDiagrams } from '../../hooks/use-diagrams' +import { useDiagram, useDiagrams, useObjectDiagrams } from '../../hooks/use-diagrams' import { useCanvasStore } from '../../stores/canvas-store' -import type { ModelObject, ObjectScope, ObjectStatus } from '../../types/model' -import { STATUS_COLORS, TYPE_ICONS, TYPE_LABELS } from '../canvas/node-utils' +import type { DiagramType, ModelObject, ObjectScope, ObjectStatus, ObjectType } from '../../types/model' +import { getObjectTypeLabel, STATUS_COLORS, TYPE_ICONS, TYPE_LABELS } from '../canvas/node-utils' import { RichTextEditor } from '../common/RichTextEditor' import { CreateChildDiagramModal, @@ -86,6 +86,7 @@ export function ObjectSidebar({ } const isStandalone = context === 'standalone' const { data: obj } = useObject(effectiveObjectId) + const { data: diagram } = useDiagram(diagramId) const updateObject = useUpdateObject() const deleteObject = useDeleteObject() @@ -123,8 +124,12 @@ export function ObjectSidebar({ updateObject.mutate({ id: obj.id, [field]: value, from_diagram_id: diagramId, from_draft_id: draftId }) } - const typeLabel = TYPE_LABELS[obj.type] ?? obj.type - const levelLabel = `L${obj.c4_level ?? '?'}` + const diagramType = diagram?.type as DiagramType | undefined + const typeLabel = getObjectTypeLabel(obj.type, diagramType) ?? obj.type + const levelLabel = diagramType === 'custom' && obj.type === 'component' + ? 'L4' + : `L${obj.c4_level ?? '?'}` + const canDrill = DRILLABLE_TYPES.has(obj.type) && diagramType !== 'custom' return (
@@ -189,8 +194,8 @@ export function ObjectSidebar({ onChange={(e) => handleFieldChange('type', e.target.value)} className="bg-surface border border-border-base text-text-2 text-[12.5px] rounded-md px-2.5 py-1.5 w-full" > - {Object.entries(TYPE_LABELS).map(([value, label]) => ( - + {Object.entries(TYPE_LABELS).map(([value]) => ( + ))}
@@ -296,7 +301,7 @@ export function ObjectSidebar({ {/* Drill into — only for drillable types, canvas context only */} - {!isStandalone && DRILLABLE_TYPES.has(obj.type) && ( + {!isStandalone && canDrill && ( )} diff --git a/frontend/src/components/toolbar/AddObjectToolbar.tsx b/frontend/src/components/toolbar/AddObjectToolbar.tsx index 95bfef4..612fa32 100644 --- a/frontend/src/components/toolbar/AddObjectToolbar.tsx +++ b/frontend/src/components/toolbar/AddObjectToolbar.tsx @@ -8,21 +8,13 @@ import { } from '../../hooks/use-api' import { useDiagram } from '../../hooks/use-diagrams' import { useCanvasStore } from '../../stores/canvas-store' -import type { CommentType, DiagramType, ObjectType } from '../../types/model' +import { C4_DIAGRAM_LEVEL_LABELS, type CommentType, type DiagramType, type ObjectType } from '../../types/model' import { detectParentGroup, nodeToRect } from '../canvas/group-utils' -import { TYPE_ICONS, TYPE_LABELS } from '../canvas/node-utils' +import { getObjectTypeLabel, TYPE_ICONS } from '../canvas/node-utils' import { ObjectContextMenu } from '../common/ObjectContextMenu' const ALL_QUICK_TYPES: ObjectType[] = ['system', 'actor', 'external_system', 'app', 'store', 'group'] -const DIAGRAM_LEVEL_LABEL: Record = { - system_landscape: 'L1 · System Landscape', - system_context: 'L1 · System Context', - container: 'L2 · Container', - component: 'L3 · Component', - custom: 'Custom', -} - function getQuickTypesForDiagram(diagramType: DiagramType | undefined): ObjectType[] { if (!diagramType) return ALL_QUICK_TYPES switch (diagramType) { @@ -37,6 +29,9 @@ function getQuickTypesForDiagram(diagramType: DiagramType | undefined): ObjectTy case 'component': return ['component', 'system', 'external_system', 'actor', 'group'] case 'custom': + // C4 L4 is the Code diagram. The backend reuses the `component` object + // type for code-level elements, so label it as Code in this context. + return ['component', 'group'] default: return ALL_QUICK_TYPES } @@ -62,8 +57,9 @@ export function AddObjectToolbar({ diagramId }: AddObjectToolbarProps) { // draft so they don't leak into the live model. const { data: diagram } = useDiagram(diagramId) const draftId = diagram?.draft_id ?? null - const quickTypes = getQuickTypesForDiagram(diagram?.type as DiagramType | undefined) - const levelLabel = diagram?.type ? DIAGRAM_LEVEL_LABEL[diagram.type as DiagramType] : null + const diagramType = diagram?.type as DiagramType | undefined + const quickTypes = getQuickTypesForDiagram(diagramType) + const levelLabel = diagramType ? C4_DIAGRAM_LEVEL_LABELS[diagramType] : null const { data: objects = [] } = useObjects(draftId) const { data: diagramObjects = [] } = useDiagramObjects(diagramId) const createObject = useCreateObject(draftId) @@ -108,7 +104,7 @@ export function AddObjectToolbar({ diagramId }: AddObjectToolbarProps) { } const handleCreateNew = (type: ObjectType) => { - const name = prompt(`New ${TYPE_LABELS[type]} name:`) + const name = prompt(`New ${getObjectTypeLabel(type, diagramType)} name:`) if (!name?.trim()) return const placementX = 200 + Math.random() * 300 const placementY = 150 + Math.random() * 250 @@ -139,17 +135,11 @@ export function AddObjectToolbar({ diagramId }: AddObjectToolbarProps) { } return ( -
+
@@ -252,61 +220,35 @@ export function AddObjectToolbar({ diagramId }: AddObjectToolbarProps) {
{/* Quick create */} -
-
+
+
Or create new
-
+
{quickTypes.map((type) => ( ))}
{/* Add comment — enters compose mode; next canvas click drops the pin */} -
-
+
+
Add comment
-
+
{COMMENT_TYPES.map((c) => ( ))}
-
+
Then click on the canvas to place the pin.
diff --git a/frontend/src/hooks/use-api-canvas-races.test.tsx b/frontend/src/hooks/use-api-canvas-races.test.tsx new file mode 100644 index 0000000..4cb31fb --- /dev/null +++ b/frontend/src/hooks/use-api-canvas-races.test.tsx @@ -0,0 +1,124 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ReactNode } from 'react' +import type { Connection } from '../types/model' +import { + clearConnectionDeleted, + clearDiagramObjectRemoved, + markConnectionDeleted, +} from './use-realtime' + +const h = vi.hoisted(() => ({ + api: { + post: vi.fn(), + delete: vi.fn(), + put: vi.fn(), + get: vi.fn(), + }, +})) + +vi.mock('../lib/api-client', () => ({ + api: h.api, +})) + +import { + type DiagramObjectData, + useAddObjectToDiagram, + useRemoveObjectFromDiagram, + useUpdateConnection, +} from './use-api' + +function wrapperFor(qc: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('canvas add/remove race cache handling', () => { + let qc: QueryClient + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + h.api.post.mockReset() + h.api.delete.mockReset() + h.api.put.mockReset() + h.api.get.mockReset() + clearDiagramObjectRemoved('d1', 'o1') + clearConnectionDeleted('c1') + }) + + it('optimistically inserts a diagram placement and replaces it with the committed row', async () => { + qc.setQueryData(['diagram-objects', 'd1'], []) + let resolvePost!: (value: { data: DiagramObjectData }) => void + h.api.post.mockReturnValue(new Promise((resolve) => { resolvePost = resolve })) + + const { result } = renderHook(() => useAddObjectToDiagram(), { wrapper: wrapperFor(qc) }) + + act(() => { + result.current.mutate({ diagramId: 'd1', objectId: 'o1', x: 10, y: 20 }) + }) + + await waitFor(() => { + expect(qc.getQueryData(['diagram-objects', 'd1'])).toMatchObject([ + { diagram_id: 'd1', object_id: 'o1', position_x: 10, position_y: 20 }, + ]) + }) + + act(() => { + resolvePost({ data: { id: 'server-row', diagram_id: 'd1', object_id: 'o1', position_x: 10, position_y: 20, width: null, height: null } }) + }) + + await waitFor(() => { + expect(qc.getQueryData(['diagram-objects', 'd1'])).toEqual([ + { id: 'server-row', diagram_id: 'd1', object_id: 'o1', position_x: 10, position_y: 20, width: null, height: null }, + ]) + }) + }) + + it('rolls back optimistic placement removal when delete fails', async () => { + const existing = { id: 'row-1', diagram_id: 'd1', object_id: 'o1', position_x: 0, position_y: 0, width: null, height: null } + qc.setQueryData(['diagram-objects', 'd1'], [existing]) + h.api.delete.mockRejectedValue(new Error('nope')) + + const { result } = renderHook(() => useRemoveObjectFromDiagram(), { wrapper: wrapperFor(qc) }) + + await act(async () => { + await result.current.mutateAsync({ diagramId: 'd1', objectId: 'o1' }).catch(() => undefined) + }) + + expect(qc.getQueryData(['diagram-objects', 'd1'])).toEqual([existing]) + }) + + it('does not apply a stale connection update after the connection was deleted', async () => { + const updated: Connection = { + id: 'c1', + source_id: 'a', + target_id: 'b', + label: 'stale', + protocol_ids: null, + direction: 'unidirectional', + tags: null, + source_handle: null, + target_handle: null, + shape: 'smoothstep', + label_size: 11, + via_object_ids: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } + qc.setQueryData(['connections', { draftId: null }], []) + markConnectionDeleted('c1') + h.api.put.mockResolvedValue({ data: updated }) + + const { result } = renderHook(() => useUpdateConnection(), { wrapper: wrapperFor(qc) }) + + await act(async () => { + await result.current.mutateAsync({ id: 'c1', label: 'stale' }) + }) + + expect(qc.getQueryData(['connections', { draftId: null }])).toEqual([]) + expect(qc.getQueryData(['connections', 'c1'])).toBeUndefined() + }) +}) diff --git a/frontend/src/hooks/use-api.ts b/frontend/src/hooks/use-api.ts index fda4f9a..2d5f69a 100644 --- a/frontend/src/hooks/use-api.ts +++ b/frontend/src/hooks/use-api.ts @@ -1,5 +1,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { + clearConnectionDeleted, + clearDiagramObjectRemoved, + isConnectionDeleted, + isDiagramObjectRemoved, + markConnectionDeleted, markDiagramObjectRemoved, markObjectDeleted, } from './use-realtime' @@ -273,7 +278,67 @@ export function useCreateConnection(draftId?: string | null) { }) return data }, - onSuccess: () => qc.invalidateQueries({ queryKey: ['connections'] }), + onMutate: async (conn) => { + await qc.cancelQueries({ queryKey: ['connections'] }) + const tempId = `optimistic:${Date.now()}:${Math.random()}` + const now = new Date().toISOString() + const optimistic: Connection = { + id: tempId, + source_id: conn.source_id, + target_id: conn.target_id, + label: conn.label ?? null, + protocol_ids: conn.protocol_ids ?? null, + direction: conn.direction ?? 'unidirectional', + tags: conn.tags ?? null, + source_handle: conn.source_handle ?? null, + target_handle: conn.target_handle ?? null, + shape: conn.shape ?? 'smoothstep', + label_size: 11, + via_object_ids: null, + created_at: now, + updated_at: now, + } + qc.setQueriesData( + { queryKey: ['connections'] }, + (prev) => (Array.isArray(prev) ? [...prev, optimistic] : prev), + ) + return { tempId } + }, + onError: (_err, _vars, context) => { + if (context?.tempId) { + qc.setQueriesData( + { queryKey: ['connections'] }, + (prev) => (Array.isArray(prev) ? prev.filter((c) => c.id !== context.tempId) : prev), + ) + } + }, + onSuccess: (created, _vars, context) => { + if (isConnectionDeleted(created.id)) { + if (context?.tempId) { + qc.setQueriesData( + { queryKey: ['connections'] }, + (prev) => (Array.isArray(prev) ? prev.filter((c) => c.id !== context.tempId) : prev), + ) + } + return + } + clearConnectionDeleted(created.id) + qc.setQueryData(['connections', created.id], created) + qc.setQueriesData( + { queryKey: ['connections'] }, + (prev) => { + if (!Array.isArray(prev)) return prev + const withoutTemp = context?.tempId + ? prev.filter((c) => c.id !== context.tempId) + : prev + const idx = withoutTemp.findIndex((c) => c.id === created.id) + if (idx === -1) return [...withoutTemp, created] + const next = [...withoutTemp] + next[idx] = created + return next + }, + ) + }, }) } @@ -319,8 +384,63 @@ export function useAddObjectToDiagram() { }) return data }, - onSuccess: (_, vars) => { - qc.invalidateQueries({ queryKey: ['diagram-objects', vars.diagramId] }) + onMutate: async ({ diagramId, objectId, x, y }) => { + await qc.cancelQueries({ queryKey: ['diagram-objects', diagramId] }) + clearDiagramObjectRemoved(diagramId, objectId) + const tempId = `optimistic:${diagramId}:${objectId}:${Date.now()}` + const optimistic: DiagramObjectData = { + id: tempId, + diagram_id: diagramId, + object_id: objectId, + position_x: x, + position_y: y, + width: null, + height: null, + } + qc.setQueriesData( + { queryKey: ['diagram-objects', diagramId] }, + (prev) => { + if (!Array.isArray(prev)) return prev + if (prev.some((r) => r.object_id === objectId)) return prev + return [...prev, optimistic] + }, + ) + return { tempId } + }, + onError: (_err, vars, context) => { + markDiagramObjectRemoved(vars.diagramId, vars.objectId) + if (context?.tempId) { + qc.setQueriesData( + { queryKey: ['diagram-objects', vars.diagramId] }, + (prev) => (Array.isArray(prev) ? prev.filter((r) => r.id !== context.tempId) : prev), + ) + } + }, + onSuccess: (created: DiagramObjectData, vars, context) => { + if (isDiagramObjectRemoved(vars.diagramId, vars.objectId)) { + if (context?.tempId) { + qc.setQueriesData( + { queryKey: ['diagram-objects', vars.diagramId] }, + (prev) => (Array.isArray(prev) ? prev.filter((r) => r.id !== context.tempId) : prev), + ) + } + return + } + clearDiagramObjectRemoved(vars.diagramId, vars.objectId) + qc.setQueriesData( + { queryKey: ['diagram-objects', vars.diagramId] }, + (prev) => { + if (!Array.isArray(prev)) return prev + const withoutTemp = context?.tempId + ? prev.filter((r) => r.id !== context.tempId) + : prev + const idx = withoutTemp.findIndex((r) => r.object_id === created.object_id) + if (idx === -1) return [...withoutTemp, created] + const next = [...withoutTemp] + next[idx] = created + return next + }, + ) }, }) } @@ -403,16 +523,23 @@ export function useRemoveObjectFromDiagram() { // the same row is already queued in the microtask stack, it will // see the tombstone and decline to re-insert. markDiagramObjectRemoved(diagramId, objectId) + const prevRows = qc.getQueryData(['diagram-objects', diagramId]) qc.setQueriesData( { queryKey: ['diagram-objects', diagramId] }, (prev) => (prev ? prev.filter((r) => r.object_id !== objectId) : prev), ) + return { prevRows } + }, + onError: (_err, vars, context) => { + clearDiagramObjectRemoved(vars.diagramId, vars.objectId) + if (context?.prevRows) { + qc.setQueryData(['diagram-objects', vars.diagramId], context.prevRows) + } }, onSuccess: (_, vars) => { // Refresh tombstone so it outlives the server round-trip + any // late-arriving WS echoes. markDiagramObjectRemoved(vars.diagramId, vars.objectId) - qc.invalidateQueries({ queryKey: ['diagram-objects', vars.diagramId] }) }, }) } @@ -429,7 +556,31 @@ export function useDeleteConnection() { } await api.delete(`/connections/${id}`, { params: Object.keys(params).length ? params : undefined }) }, - onSuccess: () => qc.invalidateQueries({ queryKey: ['connections'] }), + onMutate: async (vars) => { + const id = typeof vars === 'string' ? vars : vars.id + await qc.cancelQueries({ queryKey: ['connections'] }) + markConnectionDeleted(id) + const prevItem = qc.getQueryData(['connections', id]) + const prevLists = qc.getQueriesData({ queryKey: ['connections'] }) + qc.setQueriesData( + { queryKey: ['connections'] }, + (prev) => (Array.isArray(prev) ? prev.filter((c) => c.id !== id) : prev), + ) + qc.removeQueries({ queryKey: ['connections', id] }) + return { id, prevItem, prevLists } + }, + onError: (_err, _vars, context) => { + if (!context) return + clearConnectionDeleted(context.id) + if (context.prevItem) qc.setQueryData(['connections', context.id], context.prevItem) + for (const [key, data] of context.prevLists) { + qc.setQueryData(key, data) + } + }, + onSuccess: (_data, vars) => { + const id = typeof vars === 'string' ? vars : vars.id + markConnectionDeleted(id) + }, }) } @@ -482,6 +633,7 @@ export function useUpdateConnection() { qc.invalidateQueries({ queryKey: ['connections'] }) }, onSuccess: (updated) => { + if (isConnectionDeleted(updated.id)) return // Write the updated connection into the individual-item cache so the // sidebar reflects changes (direction, shape, etc.) without a refetch. qc.setQueryData(['connections', updated.id], updated) @@ -525,6 +677,7 @@ export function useFlipConnection() { return result }, onSuccess: (updated) => { + if (isConnectionDeleted(updated.id)) return qc.setQueryData(['connections', updated.id], updated) qc.setQueriesData( { queryKey: ['connections'] }, diff --git a/frontend/src/hooks/use-realtime.ts b/frontend/src/hooks/use-realtime.ts index a9fc0e7..391e7d4 100644 --- a/frontend/src/hooks/use-realtime.ts +++ b/frontend/src/hooks/use-realtime.ts @@ -67,6 +67,7 @@ const TOMBSTONE_TTL_MS = 5_000 const diagramObjectTombstones = new Map() // `${diagramId}:${objectId}` → expiresAt const objectTombstones = new Map() // objectId → expiresAt +const connectionTombstones = new Map() // connectionId → expiresAt function isTombstoned(map: Map, key: string): boolean { const exp = map.get(key) @@ -88,6 +89,14 @@ export function markDiagramObjectRemoved(diagramId: string, objectId: string): v ) } +export function clearDiagramObjectRemoved(diagramId: string, objectId: string): void { + diagramObjectTombstones.delete(`${diagramId}:${objectId}`) +} + +export function isDiagramObjectRemoved(diagramId: string, objectId: string): boolean { + return isTombstoned(diagramObjectTombstones, `${diagramId}:${objectId}`) +} + /** Mark an object as just-deleted; subsequent WS `object.updated` or * `diagram_object.added|updated` payloads for the same object id are * ignored inside the TTL window. */ @@ -95,6 +104,20 @@ export function markObjectDeleted(objectId: string): void { objectTombstones.set(objectId, Date.now() + TOMBSTONE_TTL_MS) } +/** Mark a connection as just-deleted; subsequent WS create/update payloads + * for the same id are ignored inside the TTL window. */ +export function markConnectionDeleted(connectionId: string): void { + connectionTombstones.set(connectionId, Date.now() + TOMBSTONE_TTL_MS) +} + +export function clearConnectionDeleted(connectionId: string): void { + connectionTombstones.delete(connectionId) +} + +export function isConnectionDeleted(connectionId: string): boolean { + return isTombstoned(connectionTombstones, connectionId) +} + /** Merge or insert an entity into a (possibly undefined) id-keyed list. * Used by useWorkspaceSocket to patch TanStack cache on object/connection/ * diagram events without hitting the network. @@ -312,22 +335,27 @@ export function useDiagramSocket(diagramId: string | null): DiagramSocketResult type === 'connection.updated' ) { const conn = msg.connection as { id: string } | undefined - if (conn) { + if (conn && isTombstoned(connectionTombstones, conn.id)) { + // swallow stale create/update for a just-deleted connection + } else if (conn) { queryClient.setQueriesData( { queryKey: ['connections'] }, (prev: unknown) => mergeEntity(prev as never, conn), ) + queryClient.setQueryData(['connections', conn.id], conn as never) } else { void queryClient.invalidateQueries({ queryKey: ['connections'] }) } } else if (type === 'connection.deleted') { const id = msg.id as string | undefined if (id) { + markConnectionDeleted(id) queryClient.setQueriesData( { queryKey: ['connections'] }, (prev: unknown) => filterList<{ id: string }>(prev, (c) => c.id !== id), ) + queryClient.removeQueries({ queryKey: ['connections', id] }) } else { void queryClient.invalidateQueries({ queryKey: ['connections'] }) } @@ -615,22 +643,27 @@ export function useWorkspaceSocket(): void { } } else if (type === 'connection.created' || type === 'connection.updated') { const conn = msg.connection as { id: string } | undefined - if (conn) { + if (conn && isTombstoned(connectionTombstones, conn.id)) { + // swallow stale create/update for a just-deleted connection + } else if (conn) { queryClient.setQueriesData( { queryKey: ['connections'] }, (prev: unknown) => mergeEntity(prev as never, conn), ) + queryClient.setQueryData(['connections', conn.id], conn as never) } else { void queryClient.invalidateQueries({ queryKey: ['connections'] }) } } else if (type === 'connection.deleted') { const id = msg.id as string | undefined if (id) { + markConnectionDeleted(id) queryClient.setQueriesData( { queryKey: ['connections'] }, (prev: unknown) => filterList<{ id: string }>(prev, (c) => c.id !== id), ) + queryClient.removeQueries({ queryKey: ['connections', id] }) } else { void queryClient.invalidateQueries({ queryKey: ['connections'] }) } diff --git a/frontend/src/index-css.test.ts b/frontend/src/index-css.test.ts index afa2001..6e5d1e6 100644 --- a/frontend/src/index-css.test.ts +++ b/frontend/src/index-css.test.ts @@ -5,6 +5,9 @@ const css = readFileSync('src/index.css', 'utf8') const exportToolbar = readFileSync('src/components/toolbar/ExportToolbar.tsx', 'utf8') const flowsPanel = readFileSync('src/components/toolbar/FlowsPanel.tsx', 'utf8') const filterToolbar = readFileSync('src/components/toolbar/FilterToolbar.tsx', 'utf8') +const addObjectToolbar = readFileSync('src/components/toolbar/AddObjectToolbar.tsx', 'utf8') +const addObjectFab = readFileSync('src/components/canvas/AddObjectFAB.tsx', 'utf8') +const richTextEditor = readFileSync('src/components/common/RichTextEditor.tsx', 'utf8') describe('light theme legacy control compatibility CSS', () => { it('keeps legacy dark button/control utilities scoped to light theme tokens', () => { @@ -32,4 +35,34 @@ describe('light theme legacy control compatibility CSS', () => { expect(filterToolbar).not.toContain("background: '#171717'") expect(flowsPanel).not.toContain("background: open ? '#333' : '#262626'") }) + + it('keeps the add-object popover viewport constrained and theme-tokened', () => { + expect(css).toContain('.add-object-toolbar__popover') + expect(css).toContain('position: fixed') + expect(css).toContain('top: clamp(12px, calc(50vh - 220px), calc(100vh - 452px))') + expect(css).toContain('max-height: calc(100vh - 24px)') + expect(css).toContain('min-height: 0') + expect(css).toContain('overflow-y: auto') + expect(css).toContain('background: var(--color-panel)') + expect(addObjectToolbar).toContain('className="add-object-toolbar__popover"') + expect(addObjectToolbar).not.toContain("background: '#171717'") + expect(addObjectToolbar).not.toContain("background: '#262626'") + expect(addObjectFab).toContain('popupMetrics') + expect(addObjectFab).toContain('window.innerHeight') + expect(addObjectFab).toContain('window.innerWidth') + expect(addObjectFab).toContain('bottom = 12') + expect(addObjectFab).not.toContain('top: 72,') + expect(addObjectFab).not.toContain('bottom: 72,') + }) + + it('keeps the rich text editor on theme classes instead of dark inline colors', () => { + expect(css).toContain('.rich-text-editor') + expect(css).toContain('color: var(--color-text-base)') + expect(css).toContain('background: var(--color-panel)') + expect(richTextEditor).toContain("className=\"rich-text-editor\"") + expect(richTextEditor).toContain("class: 'rich-text-editor__content'") + expect(richTextEditor).not.toContain('#171717') + expect(richTextEditor).not.toContain('#e5e5e5') + expect(richTextEditor).not.toContain('#333') + }) }) diff --git a/frontend/src/index.css b/frontend/src/index.css index a84b220..e14a050 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -96,6 +96,211 @@ --rich-text-code-bg: #262626; } +/* ─── Add object toolbar ─────────────────────────────────────────────────── */ +.add-object-toolbar { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + z-index: 10; +} + +.add-object-toolbar__trigger { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--control-button-bg); + border: 1px solid var(--control-border); + color: var(--context-menu-text); + cursor: pointer; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.add-object-toolbar__trigger[aria-expanded='true'], +.add-object-toolbar__trigger:hover { + background: var(--control-button-hover); +} + +.add-object-toolbar__scrim { + position: fixed; + inset: 0; + z-index: 9; +} + +.add-object-toolbar__popover { + position: fixed; + left: 68px; + top: clamp(12px, calc(50vh - 220px), calc(100vh - 452px)); + width: min(280px, calc(100vw - 84px)); + max-height: calc(100vh - 24px); + background: var(--color-panel); + border: 1px solid var(--color-border-hi); + border-radius: 8px; + box-shadow: var(--shadow-popup); + z-index: 10; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.add-object-toolbar__header, +.add-object-toolbar__section { + flex: 0 0 auto; + padding: 10px 12px; + border-bottom: 1px solid var(--color-border-base); +} + +.add-object-toolbar__section { + border-top: 1px solid var(--color-border-base); + border-bottom: 0; + padding-block: 8px; +} + +.add-object-toolbar__eyebrow { + font-size: 11px; + color: var(--color-text-3); + margin-bottom: 2px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.add-object-toolbar__section-title { + font-size: 10px; + margin-bottom: 4px; +} + +.add-object-toolbar__level, +.add-object-toolbar__hint, +.add-object-toolbar__empty, +.add-object-toolbar__type-label { + color: var(--color-text-4); +} + +.add-object-toolbar__level { + font-size: 10px; + margin-bottom: 6px; +} + +.add-object-toolbar__search { + width: 100%; + background: var(--color-bg); + border: 1px solid var(--color-border-hi); + border-radius: 4px; + padding: 6px 8px; + color: var(--color-text-base); + font-size: 12px; + outline: none; + box-sizing: border-box; +} + +.add-object-toolbar__search:focus { + border-color: var(--color-coral); +} + +.add-object-toolbar__list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.add-object-toolbar__empty { + padding: 16px; + font-size: 12px; + text-align: center; +} + +.add-object-toolbar__row { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 12px 2px 0; +} + +.add-object-toolbar__row:hover { + background: var(--color-surface-hi); +} + +.add-object-toolbar__object-button { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + padding: 6px 12px; + background: transparent; + border: none; + color: var(--context-menu-text); + cursor: pointer; + font-size: 12px; + text-align: left; +} + +.add-object-toolbar__object-button:disabled { + color: var(--color-text-3); + cursor: default; +} + +.add-object-toolbar__object-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.add-object-toolbar__in-diagram { + font-size: 9px; + color: var(--color-accent-blue); +} + +.add-object-toolbar__type-label, +.add-object-toolbar__hint { + font-size: 10px; +} + +.add-object-toolbar__hint { + margin-top: 4px; +} + +.add-object-toolbar__button-grid { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.add-object-toolbar__pill { + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + background: var(--control-button-hover); + border: 1px solid var(--control-border); + color: var(--control-text); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +} + +.add-object-toolbar__pill:hover { + background: var(--color-border-hi); + color: var(--control-text-hover); +} + +@media (max-width: 380px) { + .add-object-toolbar { + left: 8px; + } + + .add-object-toolbar__popover { + left: 56px; + width: calc(100vw - 64px); + } +} + :root[data-theme='light'] { color-scheme: light; @@ -330,6 +535,66 @@ select { padding-left: 16px; } +/* Theme-aware TipTap description editor. */ +.rich-text-editor { + border: 1px solid var(--color-border-base); + border-radius: 6px; + background: var(--color-panel); + overflow: hidden; +} + +.rich-text-editor__toolbar { + display: flex; + gap: 2px; + padding: 4px 6px; + border-bottom: 1px solid var(--color-border-base); +} + +.rich-text-editor__body { + padding: 8px 10px; +} + +.rich-text-editor__content { + min-height: 60px; + outline: none; + font-size: 13px; + color: var(--color-text-base); + line-height: 1.5; +} + +.rich-text-editor__content p { + margin: 0; +} + +.rich-text-editor__content p + p { + margin-top: 0.5em; +} + +.rich-text-editor__content .is-empty::before { + color: var(--color-text-4); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.rich-text-editor__tool-button { + background: transparent; + border: none; + border-radius: 4px; + color: var(--color-text-3); + cursor: pointer; + font-size: 12px; + padding: 2px 6px; + min-width: 24px; +} + +.rich-text-editor__tool-button:hover, +.rich-text-editor__tool-button--active { + background: var(--color-surface-hi); + color: var(--color-text-base); +} + /* * Kill default React Flow selection styles — we handle selection with * border-color inside each node. Removing this default box-shadow avoids a diff --git a/frontend/src/pages/DiagramPage.tsx b/frontend/src/pages/DiagramPage.tsx index e62d4c2..05eaf78 100644 --- a/frontend/src/pages/DiagramPage.tsx +++ b/frontend/src/pages/DiagramPage.tsx @@ -13,6 +13,7 @@ import { EdgeSidebar } from '../components/sidebar/EdgeSidebar' import { ObjectSidebar } from '../components/sidebar/ObjectSidebar' import { ObjectTree } from '../components/tree/ObjectTree' import { SearchModal } from '../components/nav/SearchModal' +import { ThemeToggle } from '../components/theme/ThemeToggle' import { Avatar, AvatarStack, Button, Kbd, StatusPill, type AvatarGradient } from '../components/ui' import { useDiagram, useDiagramBreadcrumbs } from '../hooks/use-diagrams' import { @@ -490,6 +491,7 @@ export function DiagramPage() { via flex; each one only owns its own width, so adding or removing a button here doesn't require recalculating offsets. */}
+ {diagramId && }
diff --git a/frontend/src/pages/DiagramsPage.tsx b/frontend/src/pages/DiagramsPage.tsx index 67078c6..55f4ebe 100644 --- a/frontend/src/pages/DiagramsPage.tsx +++ b/frontend/src/pages/DiagramsPage.tsx @@ -31,11 +31,11 @@ import { cn } from '../utils/cn' // ─── Constants ─────────────────────────────────────────────────────────────── const C4_LEVEL: Record = { - system_landscape: { label: 'Level 1', order: 1, level: 1 }, + system_landscape: { label: 'Landscape', order: 0, level: 1 }, system_context: { label: 'Level 1', order: 1, level: 1 }, container: { label: 'Level 2', order: 2, level: 2 }, component: { label: 'Level 3', order: 3, level: 3 }, - custom: { label: 'Custom', order: 9, level: 4 }, + custom: { label: 'Level 4', order: 4, level: 4 }, } const TYPE_LABELS: Record = { @@ -43,7 +43,7 @@ const TYPE_LABELS: Record = { system_context: 'System Context', container: 'Container', component: 'Component', - custom: 'Custom', + custom: 'Code', } // Types in display order for grouped table @@ -66,7 +66,7 @@ const TYPE_COLOR: Record // Level → C4 filter sidebar const LEVEL_ROWS: { level: 1 | 2 | 3 | 4; label: string; types: string[] }[] = [ - { level: 1, label: 'Level 1 · Landscape', types: ['system_landscape', 'system_context'] }, + { level: 1, label: 'Level 1 · System Context', types: ['system_landscape', 'system_context'] }, { level: 2, label: 'Level 2 · Container', types: ['container'] }, { level: 3, label: 'Level 3 · Component', types: ['component'] }, { level: 4, label: 'Level 4 · Code', types: ['custom'] }, diff --git a/frontend/src/pages/OverviewPage.tsx b/frontend/src/pages/OverviewPage.tsx index faa6ee1..dc6a6df 100644 --- a/frontend/src/pages/OverviewPage.tsx +++ b/frontend/src/pages/OverviewPage.tsx @@ -22,11 +22,11 @@ import { DiagramPreviewSvg } from '../components/common/DiagramPreviewSvg' // ─── Constants ──────────────────────────────────────────────────────────────── const DIAGRAM_TYPE_LABELS: Record = { - system_landscape: 'L1 · SYSTEM', + system_landscape: 'LANDSCAPE', system_context: 'L1 · CONTEXT', container: 'L2 · CONTAINER', component: 'L3 · COMPONENT', - custom: 'CUSTOM', + custom: 'L4 · CODE', } const DIAGRAM_TYPE_LEVEL: Record = { @@ -34,7 +34,7 @@ const DIAGRAM_TYPE_LEVEL: Record = { system_context: 1, container: 2, component: 3, - custom: 0, + custom: 4, } // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/frontend/src/pages/__tests__/DiagramPage.test.tsx b/frontend/src/pages/__tests__/DiagramPage.test.tsx index 1595955..aed5e13 100644 --- a/frontend/src/pages/__tests__/DiagramPage.test.tsx +++ b/frontend/src/pages/__tests__/DiagramPage.test.tsx @@ -84,6 +84,9 @@ vi.mock('../../components/tree/ObjectTree', () => ({ vi.mock('../../components/nav/SearchModal', () => ({ SearchModal: () => null, })) +vi.mock('../../components/theme/ThemeToggle', () => ({ + ThemeToggle: () => , +})) // ─── Stub stores ───────────────────────────────────────────────────────────── @@ -157,3 +160,17 @@ describe('DiagramPage back button', () => { expect(h.navigate).toHaveBeenCalledWith('/') }) }) + +describe('DiagramPage canvas tools', () => { + beforeEach(() => { + h.navigate.mockReset() + h.diagram = { id: 'd-current', name: 'Components', type: 'component', draft_id: null } + h.breadcrumbs = [] + }) + + it('renders the theme toggle in the canvas tool cluster', () => { + render(wrap()) + + expect(screen.getByRole('button', { name: /theme toggle/i })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/types/model.ts b/frontend/src/types/model.ts index 6ae190b..852c497 100644 --- a/frontend/src/types/model.ts +++ b/frontend/src/types/model.ts @@ -22,6 +22,22 @@ export type DiagramType = | 'component' | 'custom' +export const C4_DIAGRAM_LABELS: Record = { + system_landscape: 'System Landscape', + system_context: 'System Context', + container: 'Container', + component: 'Component', + custom: 'Code', +} + +export const C4_DIAGRAM_LEVEL_LABELS: Record = { + system_landscape: 'Landscape', + system_context: 'L1 · System Context', + container: 'L2 · Container', + component: 'L3 · Component', + custom: 'L4 · Code', +} + export interface ModelObject { id: string name: string