Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,14 +377,15 @@ 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<string, unknown> | 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 }
: undefined

// 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 }
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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}
/>
Expand Down
13 changes: 12 additions & 1 deletion apps/editor/src/ui/editor/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ type EditorCanvasProps = {
onDiscoverAlignedTargets?: (sourceId: string) => Promise<Array<{ targetId: string; alignmentDocId: string }>>
/** 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 */
Expand Down Expand Up @@ -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<EditorCanvasProps>) {
export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, readOnly, cfDefinitionsExtensions, loadedFramework }: Readonly<EditorCanvasProps>) {
const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth()
const {
nodes,
Expand Down Expand Up @@ -1741,6 +1743,15 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor
<CanvasHeader
frameworkTitle={frameworkInfo.title}
frameworkSubtitle={frameworkInfo.subtitle}
// Labels mirror the home screen's framework card, so they reflect the
// last-saved framework rather than unsaved edits.
frameworkLabels={{
adoptionStatus: loadedFramework?.metadata?.adoptionStatus,
readOnly,
publicAccess: loadedFramework?.metadata?.publicAccess === true,
sourcePackageURI: mirrorStatus?.sourcePackageURI,
isModifiedFromSource: mirrorStatus?.isModifiedFromSource,
}}
userName={userName ?? undefined}
tenantId={tenantId ?? undefined}
onChangePassword={authStatus === 'authenticated' && changePassword ? () => void changePassword() : undefined}
Expand Down
27 changes: 27 additions & 0 deletions apps/editor/src/ui/editor/components/CanvasHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CanvasHeader
frameworkTitle="Skills-First Product Managers Skill Taxonomy"
frameworkSubtitle="Competency"
frameworkLabels={{ adoptionStatus: 'Derived', publicAccess: true, isModifiedFromSource: false }}
/>,
)
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(<CanvasHeader frameworkTitle="Untitled" />)
expect(screen.queryByText('Mirrored')).not.toBeInTheDocument()
expect(screen.queryByText('Public')).not.toBeInTheDocument()
})
})
9 changes: 8 additions & 1 deletion apps/editor/src/ui/editor/components/CanvasHeader.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -189,6 +190,7 @@ export type SaveStatus = 'idle' | 'saving' | 'success' | 'error'
export default function CanvasHeader({
frameworkTitle,
frameworkSubtitle,
frameworkLabels,
userName,
tenantId: _tenantId,
reserveRightForPanel,
Expand All @@ -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<typeof FrameworkLabels>
userName?: string
tenantId?: string
reserveRightForPanel?: boolean
Expand Down Expand Up @@ -306,6 +310,9 @@ export default function CanvasHeader({
{frameworkSubtitle ? <div className="truncate text-xs text-gray-500">{frameworkSubtitle}</div> : null}
</div>

{/* Outside the title block so they stay vertically centred when a subtitle is shown */}
{frameworkLabels ? <FrameworkLabels {...frameworkLabels} className="shrink-0" /> : null}

{/* Save button / status indicator */}
{onSave ? (
<div className="flex items-center gap-2">
Expand Down
2 changes: 1 addition & 1 deletion apps/editor/src/ui/editor/state/EditorContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
5 changes: 5 additions & 0 deletions apps/editor/src/ui/home/frameworkStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
84 changes: 10 additions & 74 deletions apps/editor/src/ui/shared/components/FrameworkCard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { label: string; bg: string; text: string; dot: string }> = {
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
Expand Down Expand Up @@ -101,14 +73,12 @@ export function FrameworkCard({
}: Readonly<Props>) {
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 (
<div
Expand Down Expand Up @@ -204,47 +174,13 @@ export function FrameworkCard({

{/* ── Status + meta footer ─────────────────────────────── */}
<div className="mt-auto shrink-0 flex items-center justify-between gap-2 pt-4">
<div className="flex items-center gap-2">
{adoptionStatus && statusInfo ? (
<span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold', statusInfo.bg, statusInfo.text)}>
<span className={cn('inline-block h-2 w-2 rounded-full', statusInfo.dot)} />
{statusInfo.label}
</span>
) : adoptionStatus ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-600">
<span className="inline-block h-2 w-2 rounded-full bg-gray-400" />
{adoptionStatus}
</span>
) : null}
{readOnly ? (
<span className="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-700">
Read-only
</span>
) : null}
{publicAccess ? (
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-800" title="Anyone can read this framework without signing in">
Public
</span>
) : null}
{isModifiedFromSource !== undefined ? (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold',
isModifiedFromSource
? 'bg-violet-100 text-violet-700'
: 'bg-sky-100 text-sky-700',
)}
title={
isModifiedFromSource
? sourcePackageURI ? `Forked — originally mirrored from ${sourcePackageURI}` : 'Forked — originally mirrored, now locally modified'
: sourcePackageURI ? `Mirrored from ${sourcePackageURI}` : 'Mirrored — kept in sync with its imported source'
}
>
<CloudArrowDownIcon className="h-3 w-3" />
{isModifiedFromSource ? 'Forked' : 'Mirrored'}
</span>
) : null}
</div>
<FrameworkLabels
adoptionStatus={adoptionStatus}
readOnly={readOnly}
publicAccess={publicAccess}
sourcePackageURI={sourcePackageURI}
isModifiedFromSource={isModifiedFromSource}
/>

{relDate ? (
<span className="text-[11px] text-gray-400">{relDate}</span>
Expand Down
54 changes: 54 additions & 0 deletions apps/editor/src/ui/shared/components/FrameworkLabels.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FrameworkLabels />)
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(<FrameworkLabels adoptionStatus={status} />)
expect(screen.getByText(label)).toBeInTheDocument()
})

it('shows an unrecognized adoption status as-is with the neutral badge', () => {
render(<FrameworkLabels adoptionStatus="Derived" />)
expect(screen.getByText('Derived')).toHaveClass('bg-gray-100', 'text-gray-600')
})

it('shows Read-only and Public only when set', () => {
const { rerender } = render(<FrameworkLabels />)
expect(screen.queryByText('Read-only')).not.toBeInTheDocument()
expect(screen.queryByText('Public')).not.toBeInTheDocument()

rerender(<FrameworkLabels readOnly publicAccess />)
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(<FrameworkLabels isModifiedFromSource={false} sourcePackageURI="https://example.org/pkg" />)
expect(screen.getByText('Mirrored')).toHaveAttribute('title', 'Mirrored from https://example.org/pkg')
})

it('shows Forked for a locally modified import', () => {
render(<FrameworkLabels isModifiedFromSource />)
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(<FrameworkLabels adoptionStatus="Draft" />)
expect(screen.queryByText('Mirrored')).not.toBeInTheDocument()
expect(screen.queryByText('Forked')).not.toBeInTheDocument()
})
})
Loading
Loading