From 102c9bfb7946d4ac7edf3e984fb4b2891abfc7ac Mon Sep 17 00:00:00 2001
From: Alan Lail
Date: Tue, 29 Sep 2026 12:42:40 -0400
Subject: [PATCH] When creating a new framework persist a base model on the
server before transitioning author into tree view.
---
apps/editor/src/app/App.tsx | 74 +++++-------
.../src/ui/home/CreateFrameworkDialog.tsx | 89 +++++++++++---
apps/editor/src/ui/home/HomeScreen.tsx | 110 ++----------------
.../src/ui/home/UploadFrameworkDialog.tsx | 61 ++++++++--
.../ui/shared/components/FrameworkCard.tsx | 8 --
5 files changed, 155 insertions(+), 187 deletions(-)
diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx
index f9dc9e3..e54c7ad 100644
--- a/apps/editor/src/app/App.tsx
+++ b/apps/editor/src/app/App.tsx
@@ -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'
@@ -80,10 +81,6 @@ function AppInner() {
const [tenantCfLicenses, setTenantCfLicenses] = useState([])
const [tenantCfAssociationGroupings, setTenantCfAssociationGroupings] = useState([])
- // Track which framework IDs have been published to OpenCASE
- // (either loaded from the server or successfully saved)
- const [publishedFrameworkIds, setPublishedFrameworkIds] = useState>(new Set())
-
// Server-side framework list — populated from GET /ims/case/v1p1/CFDocuments on auth
const [serverCfDocuments, setServerCfDocuments] = useState([])
@@ -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
@@ -298,18 +289,6 @@ 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) => {
@@ -317,36 +296,47 @@ function AppInner() {
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 }) => {
@@ -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')
@@ -454,7 +441,6 @@ function AppInner() {
saveFrameworks(next)
return next
})
- setPublishedFrameworkIds((prev) => new Set(prev).add(fw.id))
},
[api, mergeCfDefinitions],
)
@@ -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
@@ -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
@@ -709,10 +685,8 @@ function AppInner() {
const homeScreen = (
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}
diff --git a/apps/editor/src/ui/home/CreateFrameworkDialog.tsx b/apps/editor/src/ui/home/CreateFrameworkDialog.tsx
index 18ed7b2..4dea088 100644
--- a/apps/editor/src/ui/home/CreateFrameworkDialog.tsx
+++ b/apps/editor/src/ui/home/CreateFrameworkDialog.tsx
@@ -1,4 +1,4 @@
-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'
@@ -6,6 +6,7 @@ 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
@@ -21,20 +22,55 @@ export default function CreateFrameworkDialog({
}: {
open: boolean
onCancel: () => void
- onCreate: (_draft: CreateFrameworkDraft) => void
+ onCreate: (_draft: CreateFrameworkDraft) => Promise
}) {
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(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 (
{
const f = e.target.files?.[0] ?? null
void handleFileChange(f)
@@ -280,6 +296,7 @@ export default function UploadFrameworkDialog({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Grade 3–5 Mathematics"
+ disabled={uploading}
/>
@@ -291,6 +308,7 @@ export default function UploadFrameworkDialog({
value={frameworkType}
onChange={(e) => setFrameworkType(e.target.value)}
placeholder="e.g. K-12"
+ disabled={uploading}
/>
@@ -306,14 +324,35 @@ export default function UploadFrameworkDialog({
+
+ {uploadError && (
+
+
+
+
+
Couldn’t create framework
+
{uploadError}
+
+
+
+ )}
-
+
Cancel
-
- {parsing ? 'Reading file...' : 'Create framework'}
+ void handleUpload()}>
+ {parsing ? (
+ 'Reading file...'
+ ) : uploading ? (
+ <>
+
+ Creating…
+ >
+ ) : (
+ 'Create framework'
+ )}
diff --git a/apps/editor/src/ui/shared/components/FrameworkCard.tsx b/apps/editor/src/ui/shared/components/FrameworkCard.tsx
index c2b0b24..02d281b 100644
--- a/apps/editor/src/ui/shared/components/FrameworkCard.tsx
+++ b/apps/editor/src/ui/shared/components/FrameworkCard.tsx
@@ -66,8 +66,6 @@ type Props = {
onClick?: () => void
className?: string
children?: ReactNode
- /** Show an "Unsaved" indicator for locally-created frameworks */
- isUnsaved?: boolean
/** ISO date string for the last change — shown in the card footer */
lastChanged?: string
/** URL the framework was imported from, if known — shown in the Mirrored/Forked badge tooltip */
@@ -91,7 +89,6 @@ export function FrameworkCard({
onClick,
className,
children,
- isUnsaved,
lastChanged,
sourcePackageURI,
isModifiedFromSource,
@@ -213,11 +210,6 @@ export function FrameworkCard({
{adoptionStatus}
) : null}
- {isUnsaved ? (
-
- Unsaved
-
- ) : null}
{isModifiedFromSource !== undefined ? (