From e6ba0b364b83a6b44ea2ff9be84256a1af3aa3c7 Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Tue, 27 Oct 2026 13:45:57 -0400 Subject: [PATCH] Show framework card labels in the Tree View header (CGE-2) Move the card's status badges (adoption status, Read-only, Public, Mirrored/Forked) into a shared FrameworkLabels component and render them in the editor header. The header labels reflect the last-saved framework, and the subtitle now shows only the framework type. --- apps/editor/src/app/App.tsx | 6 +- apps/editor/src/ui/editor/EditorCanvas.tsx | 13 ++- .../editor/components/CanvasHeader.test.tsx | 27 +++++ .../src/ui/editor/components/CanvasHeader.tsx | 9 +- .../src/ui/editor/state/EditorContext.tsx | 2 +- apps/editor/src/ui/home/frameworkStore.ts | 5 + .../ui/shared/components/FrameworkCard.tsx | 84 ++------------ .../components/FrameworkLabels.test.tsx | 54 +++++++++ .../ui/shared/components/FrameworkLabels.tsx | 105 ++++++++++++++++++ 9 files changed, 226 insertions(+), 79 deletions(-) create mode 100644 apps/editor/src/ui/editor/components/CanvasHeader.test.tsx create mode 100644 apps/editor/src/ui/shared/components/FrameworkLabels.test.tsx create mode 100644 apps/editor/src/ui/shared/components/FrameworkLabels.tsx diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index b9cc7ab..bf3e418 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -377,7 +377,7 @@ function AppInner() { // extension (always present in this response — the editor's HTTP // client sends X-CASE-EDITOR, which asks the backend to include it). const opencaseExt = (pkg.CFDocument?.extensions as Record | undefined)?.['ext:opencase'] as - | { isModifiedFromSource?: boolean; sourcePackageURI?: string } + | { isModifiedFromSource?: boolean; sourcePackageURI?: string; readOnly?: boolean } | undefined const mirrorStatus = opencaseExt?.isModifiedFromSource !== undefined ? { isModifiedFromSource: opencaseExt.isModifiedFromSource, sourcePackageURI: opencaseExt.sourcePackageURI } @@ -385,6 +385,7 @@ function AppInner() { // Create a HomeFramework entry from the domain Framework const fw = createHomeFrameworkFromDomain(framework, mirrorStatus) + if (opencaseExt?.readOnly === true) fw.readOnly = true markServerVerified(fw.id) if (pkg.CFDocument?.extensions) { fw.cfDocument = { ...fw.cfDocument, extensions: pkg.CFDocument.extensions } @@ -605,7 +606,7 @@ function AppInner() { ? { isModifiedFromSource: result.isModifiedFromSource, sourcePackageURI: result.sourcePackageURI } : prev[existingIdx].mirrorStatus const next = [...prev] - next[existingIdx] = createHomeFrameworkFromDomain(framework, mirrorStatus) + next[existingIdx] = { ...createHomeFrameworkFromDomain(framework, mirrorStatus), readOnly: prev[existingIdx].readOnly } saveFrameworks(next) return next }) @@ -774,6 +775,7 @@ function AppInner() { onLoadAlignmentsForTarget={tenantId ? handleLoadAlignmentsForTarget : undefined} onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined} mirrorStatus={activeFramework.mirrorStatus} + readOnly={activeFramework.readOnly} cfDefinitionsExtensions={activeCfDefinitions?.extensions} loadedFramework={activeFramework.framework} /> diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index 31c25d3..e833beb 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -215,6 +215,8 @@ type EditorCanvasProps = { onDiscoverAlignedTargets?: (sourceId: string) => Promise> /** Mirror/fork status of the open framework, if it was ever imported */ mirrorStatus?: MirrorStatus + /** Read-only cached remote framework (CGE cache) */ + readOnly?: boolean /** Extensions on the open framework's own CFDefinitions object — the editor can't author them, so they're carried through on save */ cfDefinitionsExtensions?: CaseExtensions /** The framework as loaded — its associations to nodes outside this framework have no canvas edge, so they're carried through on save */ @@ -250,7 +252,7 @@ function RemoteLinkInternalsSync({ return null } -export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, cfDefinitionsExtensions, loadedFramework }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, readOnly, cfDefinitionsExtensions, loadedFramework }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -1741,6 +1743,15 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor void changePassword() : undefined} diff --git a/apps/editor/src/ui/editor/components/CanvasHeader.test.tsx b/apps/editor/src/ui/editor/components/CanvasHeader.test.tsx new file mode 100644 index 0000000..88c0c35 --- /dev/null +++ b/apps/editor/src/ui/editor/components/CanvasHeader.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import CanvasHeader from './CanvasHeader' + +describe('CanvasHeader', () => { + it('shows the framework labels beside the title', () => { + render( + , + ) + expect(screen.getByText('Skills-First Product Managers Skill Taxonomy')).toBeInTheDocument() + expect(screen.getByText('Competency')).toBeInTheDocument() + expect(screen.getByText('Derived')).toBeInTheDocument() + expect(screen.getByText('Public')).toBeInTheDocument() + expect(screen.getByText('Mirrored')).toBeInTheDocument() + }) + + it('shows no labels when none are given', () => { + render() + expect(screen.queryByText('Mirrored')).not.toBeInTheDocument() + expect(screen.queryByText('Public')).not.toBeInTheDocument() + }) +}) diff --git a/apps/editor/src/ui/editor/components/CanvasHeader.tsx b/apps/editor/src/ui/editor/components/CanvasHeader.tsx index ff6b89b..659eba1 100644 --- a/apps/editor/src/ui/editor/components/CanvasHeader.tsx +++ b/apps/editor/src/ui/editor/components/CanvasHeader.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { ComponentType } from 'react' +import type { ComponentProps, ComponentType } from 'react' import { Cog6ToothIcon, QuestionMarkCircleIcon, ArrowRightStartOnRectangleIcon, ChevronLeftIcon, Bars3BottomLeftIcon, SparklesIcon, CloudArrowUpIcon, CheckCircleIcon, KeyIcon, ShareIcon, QueueListIcon } from '@heroicons/react/24/solid' import { Button } from '@/ui/shared/components/ui/button' import type { CFAssociationGrouping } from '@/domain/case/types' import { CANVAS_VIEW_ENABLED } from '@/ui/editor/featureFlags' +import { FrameworkLabels } from '@/ui/shared/components/FrameworkLabels' type MenuItem = { label: string @@ -189,6 +190,7 @@ export type SaveStatus = 'idle' | 'saving' | 'success' | 'error' export default function CanvasHeader({ frameworkTitle, frameworkSubtitle, + frameworkLabels, userName, tenantId: _tenantId, reserveRightForPanel, @@ -212,6 +214,8 @@ export default function CanvasHeader({ }: { frameworkTitle: string frameworkSubtitle?: string + /** Status badges shown beside the title — the same ones as the home screen's framework card */ + frameworkLabels?: ComponentProps userName?: string tenantId?: string reserveRightForPanel?: boolean @@ -306,6 +310,9 @@ export default function CanvasHeader({ {frameworkSubtitle ?
{frameworkSubtitle}
: null} + {/* Outside the title block so they stay vertically centred when a subtitle is shown */} + {frameworkLabels ? : null} + {/* Save button / status indicator */} {onSave ? (
diff --git a/apps/editor/src/ui/editor/state/EditorContext.tsx b/apps/editor/src/ui/editor/state/EditorContext.tsx index a14ef36..ccddabe 100644 --- a/apps/editor/src/ui/editor/state/EditorContext.tsx +++ b/apps/editor/src/ui/editor/state/EditorContext.tsx @@ -436,7 +436,7 @@ export function EditorProvider({ const doc = fw?.data.cfDocument return { title: doc?.title ?? 'Framework', - subtitle: [doc?.adoptionStatus, doc?.frameworkType].filter(Boolean).join(' • ') || undefined, + subtitle: doc?.frameworkType || undefined, creator: doc?.creator, } }, [state.nodes]) diff --git a/apps/editor/src/ui/home/frameworkStore.ts b/apps/editor/src/ui/home/frameworkStore.ts index 57f8455..fec2441 100644 --- a/apps/editor/src/ui/home/frameworkStore.ts +++ b/apps/editor/src/ui/home/frameworkStore.ts @@ -32,6 +32,11 @@ export type HomeFramework = { * see `openRemoteFramework` in App.tsx. */ mirrorStatus?: { isModifiedFromSource?: boolean; sourcePackageURI?: string } + /** + * Read-only cached remote framework (CGE cache). Extracted from the + * CFDocument's `ext:opencase` extension alongside `mirrorStatus`. + */ + readOnly?: boolean } const STORAGE_KEY = 'case-editor:frameworks:v1' diff --git a/apps/editor/src/ui/shared/components/FrameworkCard.tsx b/apps/editor/src/ui/shared/components/FrameworkCard.tsx index d3192e6..c1a0aa6 100644 --- a/apps/editor/src/ui/shared/components/FrameworkCard.tsx +++ b/apps/editor/src/ui/shared/components/FrameworkCard.tsx @@ -1,37 +1,9 @@ -import { PlusIcon, TrashIcon, CloudArrowDownIcon, ArchiveBoxArrowDownIcon, ArrowUturnLeftIcon } from '@heroicons/react/24/solid' +import { PlusIcon, TrashIcon, ArchiveBoxArrowDownIcon, ArrowUturnLeftIcon } from '@heroicons/react/24/solid' import type { ReactNode } from 'react' import type { CFDocument } from '@/domain/case/types' import { Button } from '@/ui/shared/components/ui/button' import { cn } from '@/lib/utils' -import { normalizeAdoptionStatus } from '@/domain/framework/model/adoptionStatus' - -/** Status badge styling — prominent, with strong color coding */ -const STATUS_STYLE: Record = { - Draft: { - label: 'Draft', - bg: 'bg-amber-100', - text: 'text-amber-800', - dot: 'bg-[#FF941F]', - }, - 'Pending Implementation': { - label: 'Pending', - bg: 'bg-sky-100', - text: 'text-sky-800', - dot: 'bg-[#29ABE3]', - }, - Implemented: { - label: 'Published', - bg: 'bg-emerald-100', - text: 'text-emerald-800', - dot: 'bg-emerald-500', - }, - Retired: { - label: 'Retired', - bg: 'bg-gray-100', - text: 'text-gray-500', - dot: 'bg-[#919496]', - }, -} +import { FrameworkLabels } from '@/ui/shared/components/FrameworkLabels' function formatRelativeDate(iso: string | undefined): string | null { if (!iso) return null @@ -101,14 +73,12 @@ export function FrameworkCard({ }: Readonly) { const title = cfDocument.title ?? 'Untitled framework' const frameworkType = (cfDocument as { frameworkType?: string }).frameworkType - const rawAdoptionStatus = (cfDocument as { adoptionStatus?: string }).adoptionStatus - const adoptionStatus = normalizeAdoptionStatus(rawAdoptionStatus) ?? rawAdoptionStatus + const adoptionStatus = (cfDocument as { adoptionStatus?: string }).adoptionStatus const description = (cfDocument as { description?: string }).description const clickable = Boolean(onClick) const relDate = formatRelativeDate(lastChanged) - const statusInfo = adoptionStatus ? STATUS_STYLE[adoptionStatus] : undefined return (
-
- {adoptionStatus && statusInfo ? ( - - - {statusInfo.label} - - ) : adoptionStatus ? ( - - - {adoptionStatus} - - ) : null} - {readOnly ? ( - - Read-only - - ) : null} - {publicAccess ? ( - - Public - - ) : null} - {isModifiedFromSource !== undefined ? ( - - - {isModifiedFromSource ? 'Forked' : 'Mirrored'} - - ) : null} -
+ {relDate ? ( {relDate} diff --git a/apps/editor/src/ui/shared/components/FrameworkLabels.test.tsx b/apps/editor/src/ui/shared/components/FrameworkLabels.test.tsx new file mode 100644 index 0000000..18251f5 --- /dev/null +++ b/apps/editor/src/ui/shared/components/FrameworkLabels.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import { FrameworkLabels } from './FrameworkLabels' + +describe('FrameworkLabels', () => { + it('renders nothing but the wrapper when no labels apply', () => { + const { container } = render() + expect(container.firstElementChild).toBeEmptyDOMElement() + }) + + it.each([ + ['Draft', 'Draft'], + ['Pending Implementation', 'Pending'], + ['Implemented', 'Published'], + ['Retired', 'Retired'], + ['Adopted', 'Published'], + ['Deprecated', 'Retired'], + ])('shows adoption status %s as "%s"', (status, label) => { + render() + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it('shows an unrecognized adoption status as-is with the neutral badge', () => { + render() + expect(screen.getByText('Derived')).toHaveClass('bg-gray-100', 'text-gray-600') + }) + + it('shows Read-only and Public only when set', () => { + const { rerender } = render() + expect(screen.queryByText('Read-only')).not.toBeInTheDocument() + expect(screen.queryByText('Public')).not.toBeInTheDocument() + + rerender() + expect(screen.getByText('Read-only')).toBeInTheDocument() + expect(screen.getByText('Public')).toHaveAttribute('title', 'Anyone can read this framework without signing in') + }) + + it('shows Mirrored for an unmodified import, with the source in the tooltip', () => { + render() + expect(screen.getByText('Mirrored')).toHaveAttribute('title', 'Mirrored from https://example.org/pkg') + }) + + it('shows Forked for a locally modified import', () => { + render() + expect(screen.getByText('Forked')).toHaveAttribute('title', 'Forked — originally mirrored, now locally modified') + }) + + it('shows no mirror badge for a framework that was never imported', () => { + render() + expect(screen.queryByText('Mirrored')).not.toBeInTheDocument() + expect(screen.queryByText('Forked')).not.toBeInTheDocument() + }) +}) diff --git a/apps/editor/src/ui/shared/components/FrameworkLabels.tsx b/apps/editor/src/ui/shared/components/FrameworkLabels.tsx new file mode 100644 index 0000000..7c3adb7 --- /dev/null +++ b/apps/editor/src/ui/shared/components/FrameworkLabels.tsx @@ -0,0 +1,105 @@ +import { CloudArrowDownIcon } from '@heroicons/react/24/solid' +import { cn } from '@/lib/utils' +import { normalizeAdoptionStatus } from '@/domain/framework/model/adoptionStatus' + +/** Status badge styling — prominent, with strong color coding */ +const STATUS_STYLE: Record = { + Draft: { + label: 'Draft', + bg: 'bg-amber-100', + text: 'text-amber-800', + dot: 'bg-[#FF941F]', + }, + 'Pending Implementation': { + label: 'Pending', + bg: 'bg-sky-100', + text: 'text-sky-800', + dot: 'bg-[#29ABE3]', + }, + Implemented: { + label: 'Published', + bg: 'bg-emerald-100', + text: 'text-emerald-800', + dot: 'bg-emerald-500', + }, + Retired: { + label: 'Retired', + bg: 'bg-gray-100', + text: 'text-gray-500', + dot: 'bg-[#919496]', + }, +} + +type Props = { + /** Raw CFDocument adoptionStatus — legacy values are normalized, unrecognized ones shown as-is */ + adoptionStatus?: string + /** Read-only cached remote framework (CGE cache) */ + readOnly?: boolean + /** CASE API readable without signing in */ + publicAccess?: boolean + /** URL the framework was imported from, if known — shown in the Mirrored/Forked badge tooltip */ + sourcePackageURI?: string + /** Set (true or false) once a framework has been imported/mirrored; true once it's been locally modified (forked) */ + isModifiedFromSource?: boolean + className?: string +} + +/** + * Framework status badges (adoption status, Read-only, Public, Mirrored/Forked), + * shared by the home screen's framework cards and the editor header. + */ +export function FrameworkLabels({ + adoptionStatus: rawAdoptionStatus, + readOnly, + publicAccess, + sourcePackageURI, + isModifiedFromSource, + className, +}: Readonly) { + const adoptionStatus = normalizeAdoptionStatus(rawAdoptionStatus) ?? rawAdoptionStatus + const statusInfo = adoptionStatus ? STATUS_STYLE[adoptionStatus] : undefined + + return ( +
+ {adoptionStatus && statusInfo ? ( + + + {statusInfo.label} + + ) : adoptionStatus ? ( + + + {adoptionStatus} + + ) : null} + {readOnly ? ( + + Read-only + + ) : null} + {publicAccess ? ( + + Public + + ) : null} + {isModifiedFromSource !== undefined ? ( + + + {isModifiedFromSource ? 'Forked' : 'Mirrored'} + + ) : null} +
+ ) +}