diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index e7bdb59..f18a064 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -154,21 +154,27 @@ export class CaseApiClient { } /** - * Import a CFPackage from an external CASE endpoint via the OpenCASE backend. + * Import a CFPackage into the tenant's framework store via the OpenCASE backend, + * either by fetching it from an external CASE endpoint (avoiding CORS) or from a + * CFPackage JSON payload provided directly (e.g. pasted by the user). Exactly one + * of `endpointUrl` or `cfPackage` should be provided. * - * The backend fetches the package (avoiding CORS), validates it, injects - * source provenance metadata, and stores it in the tenant's framework store. + * The backend validates the package, injects source provenance metadata + * (when imported from a URL), and stores it in the tenant's framework store. */ async importCfPackage(params: { tenantId: string - endpointUrl: string + endpointUrl?: string + cfPackage?: object caseVersion?: 'v1p0' | 'v1p1' accessToken?: string }): Promise<{ status: string; id: string; version: number; validationWarnings?: string[] }> { const v = params.caseVersion ?? 'v1p1' const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/ims/case/${v}/CFPackages/import` - const body: Record = { endpointUrl: params.endpointUrl } + const body: Record = {} + if (params.endpointUrl) body.endpointUrl = params.endpointUrl + if (params.cfPackage) body.cfPackage = params.cfPackage if (params.accessToken) body.accessToken = params.accessToken const res = (await this._http.post(url, body)) as unknown diff --git a/apps/editor/src/ui/home/HomeScreen.tsx b/apps/editor/src/ui/home/HomeScreen.tsx index 31c749f..c0bc7aa 100644 --- a/apps/editor/src/ui/home/HomeScreen.tsx +++ b/apps/editor/src/ui/home/HomeScreen.tsx @@ -338,6 +338,23 @@ export default function HomeScreen({ return result }, [api, tenantId, loadFrameworks, onOpenRemoteFramework]) + // Handle import from pasted CFPackage JSON + const handleImportJson = useCallback(async (cfPackage: object) => { + if (!tenantId) throw new Error('Not authenticated') + const result = await api.importCfPackage({ + tenantId, + cfPackage, + }) + // Refresh the framework list + void loadFrameworks() + // Open the imported framework in the editor + if (result.id && onOpenRemoteFramework) { + void onOpenRemoteFramework(result.id) + } + setImportOpen(false) + return result + }, [api, tenantId, loadFrameworks, onOpenRemoteFramework]) + const isAuthenticated = status === 'authenticated' // IDs of server frameworks, used to exclude drafts that have since been saved @@ -895,6 +912,7 @@ export default function HomeScreen({ open={importOpen} onCancel={() => setImportOpen(false)} onImport={handleImport} + onImportJson={handleImportJson} /> { + it('imports from a URL by default', async () => { + const onImport = vi.fn().mockResolvedValue(importResult) + const onImportJson = vi.fn() + + render( + , + ) + + fireEvent.change(screen.getByLabelText('Framework URL'), { + target: { value: 'https://case.example.org/ims/case/v1p1/CFPackages/doc-1' }, + }) + fireEvent.click(screen.getByRole('button', { name: /import framework/i })) + + await waitFor(() => expect(onImport).toHaveBeenCalledWith( + 'https://case.example.org/ims/case/v1p1/CFPackages/doc-1', + undefined, + )) + expect(onImportJson).not.toHaveBeenCalled() + }) + + it('switches to Paste JSON mode and imports a parsed CFPackage', async () => { + const onImport = vi.fn() + const onImportJson = vi.fn().mockResolvedValue(importResult) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Paste JSON' })) + + const cfPackage = { CFDocument: { identifier: 'doc-1' } } + fireEvent.change(screen.getByLabelText('Framework JSON'), { + target: { value: JSON.stringify(cfPackage) }, + }) + fireEvent.click(screen.getByRole('button', { name: /import framework/i })) + + await waitFor(() => expect(onImportJson).toHaveBeenCalledWith(cfPackage)) + expect(onImport).not.toHaveBeenCalled() + }) + + it('shows an inline error for malformed JSON without calling onImportJson', async () => { + const onImport = vi.fn() + const onImportJson = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Paste JSON' })) + fireEvent.change(screen.getByLabelText('Framework JSON'), { + target: { value: '{ not valid json' }, + }) + fireEvent.click(screen.getByRole('button', { name: /import framework/i })) + + expect(await screen.findByText(/doesn.t look like valid json/i)).toBeInTheDocument() + expect(onImportJson).not.toHaveBeenCalled() + expect(onImport).not.toHaveBeenCalled() + }) + + it('surfaces validation warnings returned from a successful import', async () => { + const onImport = vi.fn() + const onImportJson = vi.fn().mockResolvedValue({ + ...importResult, + validationWarnings: ['CFDocument.title is required'], + }) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Paste JSON' })) + fireEvent.change(screen.getByLabelText('Framework JSON'), { + target: { value: '{ "CFDocument": { "identifier": "doc-1" } }' }, + }) + fireEvent.click(screen.getByRole('button', { name: /import framework/i })) + + expect(await screen.findByText('CFDocument.title is required')).toBeInTheDocument() + }) + + it('surfaces a thrown error from onImportJson', async () => { + const onImport = vi.fn() + const onImportJson = vi.fn().mockRejectedValue(new Error('import_failed: Schema validation failed')) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Paste JSON' })) + fireEvent.change(screen.getByLabelText('Framework JSON'), { + target: { value: '{ "CFDocument": { "identifier": "doc-1" } }' }, + }) + fireEvent.click(screen.getByRole('button', { name: /import framework/i })) + + expect(await screen.findByText('import_failed: Schema validation failed')).toBeInTheDocument() + }) +}) diff --git a/apps/editor/src/ui/home/ImportFrameworkDialog.tsx b/apps/editor/src/ui/home/ImportFrameworkDialog.tsx index 38f8e3b..82df2f3 100644 --- a/apps/editor/src/ui/home/ImportFrameworkDialog.tsx +++ b/apps/editor/src/ui/home/ImportFrameworkDialog.tsx @@ -10,7 +10,9 @@ import { } 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 { ArrowPathIcon, ExclamationTriangleIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid' +import { cn } from '@/lib/utils' export type ImportResult = { status: string @@ -19,23 +21,39 @@ export type ImportResult = { validationWarnings?: string[] } +type ImportMode = 'url' | 'json' + export default function ImportFrameworkDialog({ open, onCancel, onImport, + onImportJson, }: Readonly<{ open: boolean onCancel: () => void onImport: (_endpointUrl: string, _accessToken?: string) => Promise + onImportJson: (_cfPackage: object) => Promise }>) { + const [mode, setMode] = useState('url') const [endpointUrl, setEndpointUrl] = useState('') const [accessToken, setAccessToken] = useState('') + const [jsonText, setJsonText] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [importing, setImporting] = useState(false) const [error, setError] = useState(null) const [warnings, setWarnings] = useState([]) - const canImport = endpointUrl.trim().length > 0 && !importing + const canImport = !importing && ( + mode === 'url' ? endpointUrl.trim().length > 0 : jsonText.trim().length > 0 + ) + + const resetForm = useCallback(() => { + setEndpointUrl('') + setAccessToken('') + setJsonText('') + setShowAdvanced(false) + setError(null) + }, []) const handleImport = useCallback(async () => { if (!canImport) return @@ -43,34 +61,43 @@ export default function ImportFrameworkDialog({ setError(null) setWarnings([]) try { - const result = await onImport( - endpointUrl.trim(), - accessToken.trim() || undefined, - ) + let result: ImportResult + if (mode === 'url') { + result = await onImport(endpointUrl.trim(), accessToken.trim() || undefined) + } else { + let cfPackage: object + try { + cfPackage = JSON.parse(jsonText) + } catch { + setError('That doesn’t look like valid JSON. Check for a missing brace or comma and try again.') + return + } + result = await onImportJson(cfPackage) + } if (result.validationWarnings?.length) { setWarnings(result.validationWarnings) } // Reset form state on success (dialog will be closed by parent) - setEndpointUrl('') - setAccessToken('') - setShowAdvanced(false) - setError(null) + resetForm() } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) } finally { setImporting(false) } - }, [canImport, endpointUrl, accessToken, onImport]) + }, [canImport, mode, endpointUrl, accessToken, jsonText, onImport, onImportJson, resetForm]) const handleCancel = useCallback(() => { - setEndpointUrl('') - setAccessToken('') - setShowAdvanced(false) - setError(null) + resetForm() setWarnings([]) setImporting(false) onCancel() - }, [onCancel]) + }, [resetForm, onCancel]) + + const handleModeChange = useCallback((next: ImportMode) => { + setMode(next) + setError(null) + setWarnings([]) + }, []) return ( Import an existing framework - Already have a framework published on a CASE server? Paste the link below to - bring it into the editor so you can view or build on it. + Already have a framework published on a CASE server? Import it from a link, + or paste its CFPackage JSON directly, to bring it into the editor.
-
- - setEndpointUrl(e.target.value)} - placeholder="https://case.example.org/ims/case/v1p1/CFPackages/{id}" - autoFocus +
+ +
- {/* Advanced options toggle */} - + {mode === 'url' ? ( + <> +
+ + setEndpointUrl(e.target.value)} + placeholder="https://case.example.org/ims/case/v1p1/CFPackages/{id}" + autoFocus + disabled={importing} + /> +

+ Paste the full web address of the framework you want to import. + Your administrator or framework provider can give you this link. +

+
- {showAdvanced && ( + {/* Advanced options toggle */} + + + {showAdvanced && ( +
+ + setAccessToken(e.target.value)} + placeholder="Paste your access token here" + disabled={importing} + /> +

+ If the framework server requires a login, paste the access token you were given. +

+
+ )} + + ) : (
- - setAccessToken(e.target.value)} - placeholder="Paste your access token here" + +