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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { frameworkToCfPackage } from './toCasePackage'
import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader'
import type { CFPackage } from '@/domain/case/types'

const DOC_ID = 'doc-1'

function makePackage(adoptionStatus: string): CFPackage {
return {
CFDocument: {
identifier: DOC_ID,
uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`,
title: 'Curriculum',
creator: 'Importer',
lastChangeDateTime: '2026-01-01T00:00:00.000Z',
caseVersion: '1.1',
adoptionStatus,
},
CFItems: [],
CFAssociations: [],
} as unknown as CFPackage
}

// CGE-70: legacy CASE 1.0 values must survive load + save unchanged, so a save
// never rewrites a status the author didn't touch.
describe('adoptionStatus round trip', () => {
it.each(['Adopted', 'Deprecated', 'Implemented', 'Derived'])('keeps %s as stored', (status) => {
const framework = loadFrameworkFromCfPackage(makePackage(status))
if (!framework) throw new Error('failed to load framework')
expect(framework.metadata.adoptionStatus).toBe(status)

const saved = frameworkToCfPackage({ framework, caseVersion: '1.1' })
expect(saved.CFDocument.adoptionStatus).toBe(status)
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { CasePackageSnapshot } from './CasePackageSnapshot'
import type { Framework, FrameworkMetadata, Item, Association, AssociationType, ItemType } from '@/domain/framework/model/types'
import type { FrameworkId, ItemId, AssociationId } from '@/domain/shared/types'
import { normalizeAdoptionStatus } from '@/domain/framework/model/adoptionStatus'

function mapItemType(rawType?: string): ItemType | undefined {
const raw = (rawType ?? '').toLowerCase()
Expand Down Expand Up @@ -44,7 +43,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot):
caseUri: doc.uri,
publisher: doc.publisher,
frameworkType: doc.frameworkType,
adoptionStatus: normalizeAdoptionStatus(doc.adoptionStatus) ?? doc.adoptionStatus,
adoptionStatus: doc.adoptionStatus,
caseVersion: doc.caseVersion ?? (snapshot.version === 'unknown' ? undefined : snapshot.version),
version: doc.version,
language: doc.language,
Expand Down
3 changes: 3 additions & 0 deletions apps/editor/src/domain/framework/model/adoptionStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export const LEGACY_STATUS_MAP: Readonly<Record<string, string>> = {
* Normalise an adoption status value:
* 1. Map known legacy values to their modern equivalents.
* 2. Leave all other values (including custom / imported) unchanged.
*
* For classification only (badge color, draft vs. finalized) — never write
* the result back to a framework or show it in place of the stored value.
*/
export function normalizeAdoptionStatus(raw: string | undefined): string | undefined {
if (!raw) return raw
Expand Down
6 changes: 2 additions & 4 deletions apps/editor/src/ui/home/frameworkStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,12 @@ function createFrameworkFromMetadata(params: {
adoptionStatus?: string
caseVersion?: string
}): Framework {
const normalized = normalizeAdoptionStatus(params.adoptionStatus) ?? params.adoptionStatus

const metadata: FrameworkMetadata = {
title: params.title,
description: params.description,
creator: params.creator,
frameworkType: params.frameworkType,
adoptionStatus: normalized,
adoptionStatus: params.adoptionStatus,
caseVersion: params.caseVersion ?? '1.1', // Default to CASE 1.1 for new frameworks
lastChangeDateTime: new Date().toISOString(),
}
Expand All @@ -96,7 +94,7 @@ function createFrameworkFromMetadata(params: {
metadata,
items: new Map(),
associations: new Map(),
status: adoptionStatusToDomainStatus(normalized),
status: adoptionStatusToDomainStatus(params.adoptionStatus),
}
}

Expand Down
18 changes: 14 additions & 4 deletions apps/editor/src/ui/shared/components/FrameworkLabels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,26 @@ describe('FrameworkLabels', () => {

it.each([
['Draft', 'Draft'],
['Pending Implementation', 'Pending'],
['Implemented', 'Published'],
['Pending Implementation', 'Pending Implementation'],
['Implemented', 'Implemented'],
['Retired', 'Retired'],
['Adopted', 'Published'],
['Deprecated', 'Retired'],
['Adopted', 'Adopted'],
['Deprecated', 'Deprecated'],
])('shows adoption status %s as "%s"', (status, label) => {
render(<FrameworkLabels adoptionStatus={status} />)
expect(screen.getByText(label)).toBeInTheDocument()
})

it('no longer relabels Implemented as "Published"', () => {
render(<FrameworkLabels adoptionStatus="Implemented" />)
expect(screen.queryByText('Published')).not.toBeInTheDocument()
})

it('colors a legacy adoption status like its modern equivalent', () => {
render(<FrameworkLabels adoptionStatus="Adopted" />)
expect(screen.getByText('Adopted')).toHaveClass('bg-emerald-100', 'text-emerald-800')
})

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')
Expand Down
15 changes: 5 additions & 10 deletions apps/editor/src/ui/shared/components/FrameworkLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,31 @@ 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 }> = {
const STATUS_STYLE: Record<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]',
},
}

type Props = {
/** Raw CFDocument adoptionStatus — legacy values are normalized, unrecognized ones shown as-is */
/** Raw CFDocument adoptionStatus, shown as stored — legacy values take their modern equivalent's color */
adoptionStatus?: string
/** Read-only cached remote framework (CGE cache) */
readOnly?: boolean
Expand All @@ -49,22 +45,21 @@ type Props = {
* shared by the home screen's framework cards and the editor header.
*/
export function FrameworkLabels({
adoptionStatus: rawAdoptionStatus,
adoptionStatus,
readOnly,
publicAccess,
sourcePackageURI,
isModifiedFromSource,
className,
}: Readonly<Props>) {
const adoptionStatus = normalizeAdoptionStatus(rawAdoptionStatus) ?? rawAdoptionStatus
const statusInfo = adoptionStatus ? STATUS_STYLE[adoptionStatus] : undefined
const statusInfo = adoptionStatus ? STATUS_STYLE[normalizeAdoptionStatus(adoptionStatus) ?? adoptionStatus] : undefined

return (
<div className={cn('flex items-center gap-2', className)}>
{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}
{adoptionStatus}
</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">
Expand Down
Loading