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 ( { - if (!v) onCancel() + if (!v && !isCreating) handleCancel() }} > @@ -52,12 +88,19 @@ export default function CreateFrameworkDialog({ onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Grade 3–5 Mathematics" autoFocus + disabled={isCreating} />
- setFrameworkType(e.target.value)} placeholder="e.g. K-12" /> + setFrameworkType(e.target.value)} + placeholder="e.g. K-12" + disabled={isCreating} + />
@@ -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} />
+ + {error && ( +
+
+ +
+
Couldn’t create framework
+
{error}
+
+
+
+ )} - -
diff --git a/apps/editor/src/ui/home/HomeScreen.tsx b/apps/editor/src/ui/home/HomeScreen.tsx index 32dc22a..8210534 100644 --- a/apps/editor/src/ui/home/HomeScreen.tsx +++ b/apps/editor/src/ui/home/HomeScreen.tsx @@ -3,7 +3,6 @@ import { CodeBracketSquareIcon } from '@heroicons/react/24/outline' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/ui/shared/components/ui/button' import { FrameworkCard } from '@/ui/shared/components/FrameworkCard' -import type { HomeFramework } from '@/ui/home/frameworkStore' import CreateFrameworkDialog, { type CreateFrameworkDraft } from '@/ui/home/CreateFrameworkDialog' import ImportFrameworkDialog from '@/ui/home/ImportFrameworkDialog' import UploadFrameworkDialog from '@/ui/home/UploadFrameworkDialog' @@ -110,25 +109,18 @@ function UserAvatarMenu({ userName, tenantId: _tenantId, isAuthenticated, onSign } export default function HomeScreen({ - unsavedDrafts, - onOpenFramework, onOpenRemoteFramework, - onDeleteDraft, onRemoveFromStorage, remoteOpenLoading, onCreateNew, onUploadFramework, }: Readonly<{ - /** Locally-created frameworks that have not yet been saved to the server */ - unsavedDrafts: HomeFramework[] - onOpenFramework: (_id: string) => void onOpenRemoteFramework?: (_docId: string) => Promise - onDeleteDraft?: (_id: string) => void /** Remove a framework from localStorage after archive or hard delete */ onRemoveFromStorage?: (_docId: string) => void remoteOpenLoading?: boolean - onCreateNew: (_draft: CreateFrameworkDraft) => void - onUploadFramework?: (_framework: Framework) => void + onCreateNew: (_draft: CreateFrameworkDraft) => Promise + onUploadFramework?: (_framework: Framework) => Promise }>) { const [createOpen, setCreateOpen] = useState(false) const [importOpen, setImportOpen] = useState(false) @@ -172,9 +164,6 @@ export default function HomeScreen({ const [hardDeleteConfirm, setHardDeleteConfirm] = useState<{ docId: string; title: string } | null>(null) const [deletingDocId, setDeletingDocId] = useState(null) - // Delete confirmation state (for unsaved drafts) - const [draftDeleteConfirm, setDraftDeleteConfirm] = useState<{ id: string; title: string } | null>(null) - // Search & filter state const [searchQuery, setSearchQuery] = useState('') const [statusFilter, setStatusFilter] = useState('') @@ -313,13 +302,6 @@ export default function HomeScreen({ } }, [api, tenantId, loadFrameworks]) - // Handle unsaved draft delete - const handleDraftDeleteConfirm = useCallback(() => { - if (!draftDeleteConfirm || !onDeleteDraft) return - onDeleteDraft(draftDeleteConfirm.id) - setDraftDeleteConfirm(null) - }, [draftDeleteConfirm, onDeleteDraft]) - // Handle import from external CASE endpoint const handleImport = useCallback(async (endpointUrl: string, accessToken?: string) => { if (!tenantId) throw new Error('Not authenticated') @@ -357,15 +339,6 @@ export default function HomeScreen({ const isAuthenticated = status === 'authenticated' - // IDs of server frameworks, used to exclude drafts that have since been saved - const serverIds = useMemo(() => new Set(serverFrameworks.map((f) => f.identifier)), [serverFrameworks]) - - // Unsaved drafts that haven't been saved to the server yet - const visibleDrafts = useMemo( - () => unsavedDrafts.filter((d) => !serverIds.has(d.id)), - [unsavedDrafts, serverIds], - ) - // ── Search & filter helpers ──────────────────────────────────────── function matchesSearch(title?: string, creator?: string, description?: string): boolean { @@ -384,17 +357,6 @@ export default function HomeScreen({ return true } - // Filtered unsaved drafts - const filteredDrafts = useMemo( - () => - visibleDrafts.filter((d) => { - const doc = d.cfDocument - return matchesSearch(doc.title, doc.creator, doc.description) && matchesFilters(doc.adoptionStatus, doc.frameworkType) - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [visibleDrafts, searchQuery, statusFilter, typeFilter], - ) - // Filtered server frameworks const filteredServerFrameworks = useMemo( () => @@ -408,13 +370,12 @@ export default function HomeScreen({ // Collect unique framework types for the type filter dropdown const allFrameworkTypes = useMemo(() => { const types = new Set() - visibleDrafts.forEach((d) => { if (d.cfDocument.frameworkType) types.add(d.cfDocument.frameworkType) }) serverFrameworks.forEach((d) => { if (d.frameworkType && d.frameworkType !== 'Alignment') types.add(d.frameworkType) }) return Array.from(types).sort((a, b) => a.localeCompare(b)) - }, [visibleDrafts, serverFrameworks]) + }, [serverFrameworks]) const hasActiveFilters = Boolean(searchQuery || statusFilter || typeFilter) - const totalResults = filteredDrafts.length + filteredServerFrameworks.length + const totalResults = filteredServerFrameworks.length const clearFilters = useCallback(() => { setSearchQuery('') @@ -700,40 +661,6 @@ export default function HomeScreen({ )} - {/* ── Unsaved Drafts (only shown on Active tab) ──────────────── */} - {viewMode === 'active' && filteredDrafts.length > 0 && ( -
-
-

Unsaved Drafts

- - {filteredDrafts.length} - -
-

- New frameworks not yet saved to the server. Open to edit, then save to publish. -

- -
- {filteredDrafts.map((fw) => ( - onOpenFramework(fw.id)} - onDelete={ - onDeleteDraft - ? () => setDraftDeleteConfirm({ id: fw.id, title: fw.cfDocument.title ?? 'Untitled' }) - : undefined - } - actionStyle="delete" - /> - ))} -
-
- )} - {/* ── Server Frameworks ──────────────────────────────────────── */}
@@ -902,9 +829,9 @@ export default function HomeScreen({ setCreateOpen(false)} - onCreate={(draft) => { + onCreate={async (draft) => { + await onCreateNew(draft) setCreateOpen(false) - onCreateNew(draft) }} /> @@ -918,9 +845,9 @@ export default function HomeScreen({ setUploadOpen(false)} - onUpload={(framework) => { + onUpload={async (framework) => { + await onUploadFramework?.(framework) setUploadOpen(false) - onUploadFramework?.(framework) }} /> @@ -976,27 +903,6 @@ export default function HomeScreen({
- {/* Delete Confirmation Dialog (unsaved drafts) */} - !open && setDraftDeleteConfirm(null)}> - - - Delete Draft - - Are you sure you want to delete “{draftDeleteConfirm?.title}”? - This draft has not been saved to the server and will be permanently removed. - - - - - - - - - ) } diff --git a/apps/editor/src/ui/home/UploadFrameworkDialog.tsx b/apps/editor/src/ui/home/UploadFrameworkDialog.tsx index 2ff2b91..0d91314 100644 --- a/apps/editor/src/ui/home/UploadFrameworkDialog.tsx +++ b/apps/editor/src/ui/home/UploadFrameworkDialog.tsx @@ -12,7 +12,7 @@ import { Input } from '@/ui/shared/components/ui/input' import { Label } from '@/ui/shared/components/ui/label' import { ComboboxInput } from '@/ui/shared/components/ui/combobox-input' import { ADOPTION_STATUS_OPTIONS } from '@/domain/framework/model/adoptionStatus' -import { ExclamationTriangleIcon, ArrowUpTrayIcon, DocumentArrowDownIcon } from '@heroicons/react/24/solid' +import { ExclamationTriangleIcon, ArrowUpTrayIcon, DocumentArrowDownIcon, ArrowPathIcon } from '@heroicons/react/24/solid' import { parseSpreadsheetFile, type ParseResult } from '@/application/framework/services/SpreadsheetParser' import { spreadsheetToFramework } from '@/application/framework/services/SpreadsheetToFramework' import { downloadTemplate } from '@/ui/home/spreadsheetTemplate' @@ -25,7 +25,7 @@ export default function UploadFrameworkDialog({ }: Readonly<{ open: boolean onCancel: () => void - onUpload: (_framework: Framework) => void + onUpload: (_framework: Framework) => Promise }>) { const fileInputRef = useRef(null) @@ -39,12 +39,16 @@ export default function UploadFrameworkDialog({ const [frameworkType, setFrameworkType] = useState('K-12') const [adoptionStatus, setAdoptionStatus] = useState('Draft') + // Upload (save-to-server) state + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const hasErrors = (parseResult?.errors.length ?? 0) > 0 const rowCount = parseResult?.rows.length ?? 0 const canUpload = useMemo( - () => title.trim().length > 0 && rowCount > 0 && !hasErrors && !parsing, - [title, rowCount, hasErrors, parsing], + () => title.trim().length > 0 && rowCount > 0 && !hasErrors && !parsing && !uploading, + [title, rowCount, hasErrors, parsing, uploading], ) // Compute hierarchy depth for preview @@ -89,10 +93,12 @@ export default function UploadFrameworkDialog({ setTitle('') setFrameworkType('K-12') setAdoptionStatus('Draft') + setUploading(false) + setUploadError(null) if (fileInputRef.current) fileInputRef.current.value = '' }, []) - const handleUpload = useCallback(() => { + const handleUpload = useCallback(async () => { if (!canUpload || !parseResult) return const framework = spreadsheetToFramework(parseResult.rows, { @@ -101,8 +107,16 @@ export default function UploadFrameworkDialog({ adoptionStatus: adoptionStatus.trim() || undefined, }) - onUpload(framework) - resetForm() + setUploading(true) + setUploadError(null) + try { + await onUpload(framework) + // Reset form state on success (dialog will be closed by parent). + resetForm() + } catch (e: unknown) { + setUploadError(e instanceof Error ? e.message : String(e)) + setUploading(false) + } }, [canUpload, parseResult, title, frameworkType, adoptionStatus, onUpload, resetForm]) const handleCancel = useCallback(() => { @@ -125,7 +139,7 @@ export default function UploadFrameworkDialog({ { - if (!v) handleCancel() + if (!v && !uploading) handleCancel() }} > @@ -177,6 +191,7 @@ export default function UploadFrameworkDialog({

-
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 ? (