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
74 changes: 26 additions & 48 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/
import { createFetchHttpClient } from '@/infrastructure/caseApi/http'
import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader'
import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage } from '@/ui/editor/reactflow/mapping'
import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage'
import type { LayoutState } from '@/ui/editor/reactflow/mapping'
import type { CaseVersion } from '@/application/framework/mappers/case/CasePackageSnapshot'
import type { CFAssociationGrouping, CFItemType, CFLicense, CFSubject, CFConcept } from '@/domain/case/types'
Expand Down Expand Up @@ -80,10 +81,6 @@ function AppInner() {
const [tenantCfLicenses, setTenantCfLicenses] = useState<CFLicense[]>([])
const [tenantCfAssociationGroupings, setTenantCfAssociationGroupings] = useState<CFAssociationGrouping[]>([])

// Track which framework IDs have been published to OpenCASE
// (either loaded from the server or successfully saved)
const [publishedFrameworkIds, setPublishedFrameworkIds] = useState<Set<string>>(new Set())

// Server-side framework list — populated from GET /ims/case/v1p1/CFDocuments on auth
const [serverCfDocuments, setServerCfDocuments] = useState<CfDocumentSummary[]>([])

Expand Down Expand Up @@ -257,12 +254,6 @@ function AppInner() {
return frameworks.find((f) => f.id === activeFrameworkId) ?? null
}, [frameworks, activeFrameworkId])

// Unsaved drafts: frameworks in the local cache that haven't been published to the server
const unsavedDrafts = useMemo(
() => frameworks.filter((f) => !publishedFrameworkIds.has(f.id)),
[frameworks, publishedFrameworkIds],
)

// Summaries of server frameworks passed to the crosswalk target selector (excludes alignment frameworks)
const serverFrameworkSummaries = useMemo(
() => serverCfDocuments
Expand Down Expand Up @@ -298,55 +289,54 @@ function AppInner() {
navigateToFramework(id)
}, [navigateToFramework])

const deleteDraft = useCallback((id: string) => {
setFrameworks((prev) => {
const next = prev.filter((f) => f.id !== id)
saveFrameworks(next)
return next
})
// If we're deleting the active framework, go back to home
if (activeFrameworkId === id) {
navigateHome({ replace: true })
}
}, [activeFrameworkId, navigateHome])

/** Remove a framework from localStorage (used after archive or hard delete) */
const removeFrameworkFromStorage = useCallback((docId: string) => {
setFrameworks((prev) => {
const next = prev.filter((f) => f.id !== docId)
saveFrameworks(next)
return next
})
setPublishedFrameworkIds((prev) => {
const next = new Set(prev)
next.delete(docId)
return next
})
if (activeFrameworkId === docId) {
navigateHome({ replace: true })
}
}, [activeFrameworkId, navigateHome])

const createNew = useCallback((draft: CreateFrameworkDraft) => {
// Persist a brand-new (never-yet-saved) framework to the server as its base
// version. Shared by the "create new" and "upload spreadsheet" flows below,
// both of which build a HomeFramework locally first but must confirm it
// exists on the server before treating it like any other framework in the
// list — leaving without an explicit save should only ever lose in-progress
// edits, never the framework's existence.
const saveNewFrameworkToServer = useCallback(async (fw: HomeFramework, tid: string) => {
const cfPackage = frameworkToCfPackage({ framework: fw.framework, caseVersion: '1.1' })
const openCasePackage = toOpenCaseFormat(cfPackage)
await api.saveCfPackage({ tenantId: tid, cfPackage: openCasePackage, caseVersion: 'v1p1' })
}, [api])

const createNew = useCallback(async (draft: CreateFrameworkDraft) => {
const fw = createNewFrameworkDraft(draft)
if (tenantId) await saveNewFrameworkToServer(fw, tenantId)

setFrameworks((prev) => {
const next = [fw, ...prev]
saveFrameworks(next)
return next
})
navigateToFramework(fw.id)
}, [navigateToFramework])
}, [navigateToFramework, tenantId, saveNewFrameworkToServer])

/** Create a HomeFramework from a pre-populated domain Framework (e.g. from spreadsheet upload). */
const createFromFramework = useCallback((framework: Framework) => {
const createFromFramework = useCallback(async (framework: Framework) => {
const fw = createHomeFrameworkFromDomain(framework)
if (tenantId) await saveNewFrameworkToServer(fw, tenantId)

setFrameworks((prev) => {
const next = [fw, ...prev]
saveFrameworks(next)
return next
})
navigateToFramework(fw.id)
}, [navigateToFramework])
}, [navigateToFramework, tenantId, saveNewFrameworkToServer])

const openRemoteFramework = useCallback(
async (docId: string, opts?: { replace?: boolean }) => {
Expand Down Expand Up @@ -408,9 +398,6 @@ function AppInner() {
return next
})

// Mark as published since it was loaded from OpenCASE
setPublishedFrameworkIds((prev) => new Set(prev).add(fw.id))

navigateToFramework(fw.id, opts)
} finally {
setRemoteOpenState('idle')
Expand Down Expand Up @@ -454,7 +441,6 @@ function AppInner() {
saveFrameworks(next)
return next
})
setPublishedFrameworkIds((prev) => new Set(prev).add(fw.id))
},
[api, mergeCfDefinitions],
)
Expand Down Expand Up @@ -534,11 +520,6 @@ function AppInner() {
caseVersion: caseApiVersion,
})

// Mark this framework as published to OpenCASE
if (activeFrameworkId) {
setPublishedFrameworkIds((prev) => new Set(prev).add(activeFrameworkId))
}

// A fork mints new identifiers server-side for the document AND every
// item/association in it — not just the document. Patching the local
// session by hand would mean re-deriving that whole remap ourselves and
Expand All @@ -555,11 +536,6 @@ function AppInner() {
saveFrameworks(next)
return next
})
setPublishedFrameworkIds((prev) => {
const next = new Set(prev)
next.delete(oldId)
return next
})
setFrameworkLayouts((prev) => {
const { [oldId]: _dropped, ...rest } = prev
return rest
Expand Down Expand Up @@ -709,10 +685,8 @@ function AppInner() {

const homeScreen = (
<HomeScreen
unsavedDrafts={unsavedDrafts}
onOpenFramework={openFramework}
onOpenRemoteFramework={openRemoteFramework}
onDeleteDraft={deleteDraft}
onRemoveFromStorage={removeFrameworkFromStorage}
remoteOpenLoading={remoteOpenState === 'loading'}
onCreateNew={createNew}
Expand Down Expand Up @@ -744,7 +718,11 @@ function AppInner() {
<EditorCanvas
onBack={() => navigateHome()}
onSaveToServer={tenantId ? handleSaveToServer : undefined}
isPublishedToOpenCase={activeFrameworkId ? publishedFrameworkIds.has(activeFrameworkId) : false}
// Every framework that reaches `frameworks` was either loaded from the server
// or saved to it before being added locally (see saveNewFrameworkToServer,
// openRemoteFramework, handleLoadTargetFramework, handleSaveToServer) — so if
// there's an active framework at all, it's known-published.
isPublishedToOpenCase={Boolean(activeFrameworkId)}
onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined}
onFetchCfPackage={activeFrameworkId ? handleFetchCfPackage : undefined}
availableFrameworks={frameworks}
Expand Down
89 changes: 71 additions & 18 deletions apps/editor/src/ui/home/CreateFrameworkDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { Button } from '@/ui/shared/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/ui/shared/components/ui/dialog'
import { Input } from '@/ui/shared/components/ui/input'
import { Label } from '@/ui/shared/components/ui/label'
import { Textarea } from '@/ui/shared/components/ui/textarea'
import { ComboboxInput } from '@/ui/shared/components/ui/combobox-input'
import { ADOPTION_STATUS_OPTIONS } from '@/domain/framework/model/adoptionStatus'
import { ArrowPathIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid'

export type CreateFrameworkDraft = {
title: string
Expand All @@ -21,20 +22,55 @@ export default function CreateFrameworkDialog({
}: {
open: boolean
onCancel: () => void
onCreate: (_draft: CreateFrameworkDraft) => void
onCreate: (_draft: CreateFrameworkDraft) => Promise<void>
}) {
const [title, setTitle] = useState('')
const [frameworkType, setFrameworkType] = useState('K-12')
const [adoptionStatus, setAdoptionStatus] = useState('Draft')
const [description, setDescription] = useState('')
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)

const canCreate = useMemo(() => title.trim().length > 0, [title])
const canCreate = useMemo(() => !isCreating && title.trim().length > 0, [title, isCreating])

const resetForm = useCallback(() => {
setTitle('')
setFrameworkType('K-12')
setAdoptionStatus('Draft')
setDescription('')
setIsCreating(false)
setError(null)
}, [])

const handleCreate = useCallback(async () => {
if (!canCreate) return
setIsCreating(true)
setError(null)
try {
await onCreate({
title: title.trim(),
frameworkType: frameworkType.trim() || undefined,
adoptionStatus: adoptionStatus.trim() || undefined,
description: description.trim() || undefined,
})
// Reset form state on success (dialog will be closed by parent).
resetForm()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
setIsCreating(false)
}
}, [canCreate, title, frameworkType, adoptionStatus, description, onCreate, resetForm])

const handleCancel = useCallback(() => {
setError(null)
onCancel()
}, [onCancel])

return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) onCancel()
if (!v && !isCreating) handleCancel()
}}
>
<DialogContent>
Expand All @@ -52,12 +88,19 @@ export default function CreateFrameworkDialog({
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Grade 3–5 Mathematics"
autoFocus
disabled={isCreating}
/>
</div>

<div className="grid gap-1.5">
<Label htmlFor="fw_type">Framework type (optional)</Label>
<Input id="fw_type" value={frameworkType} onChange={(e) => setFrameworkType(e.target.value)} placeholder="e.g. K-12" />
<Input
id="fw_type"
value={frameworkType}
onChange={(e) => setFrameworkType(e.target.value)}
placeholder="e.g. K-12"
disabled={isCreating}
/>
</div>

<div className="grid gap-1.5">
Expand All @@ -78,26 +121,36 @@ export default function CreateFrameworkDialog({
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A short description to help others understand this framework."
disabled={isCreating}
/>
</div>

{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3.5 text-base leading-relaxed text-red-800">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 shrink-0 text-red-500" />
<div>
<div className="font-semibold">Couldn&rsquo;t create framework</div>
<div className="mt-1">{error}</div>
</div>
</div>
</div>
)}
</div>

<DialogFooter>
<Button variant="secondary" onClick={onCancel}>
<Button variant="secondary" onClick={handleCancel} disabled={isCreating}>
Cancel
</Button>
<Button
disabled={!canCreate}
onClick={() =>
onCreate({
title: title.trim(),
frameworkType: frameworkType.trim() || undefined,
adoptionStatus: adoptionStatus.trim() || undefined,
description: description.trim() || undefined,
})
}
>
OK
<Button disabled={!canCreate} onClick={() => void handleCreate()}>
{isCreating ? (
<>
<ArrowPathIcon className="h-4 w-4 animate-spin" />
Creating&hellip;
</>
) : (
'OK'
)}
</Button>
</DialogFooter>
</DialogContent>
Expand Down
Loading
Loading