Skip to content
Open
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
16 changes: 11 additions & 5 deletions apps/editor/src/infrastructure/caseApi/CaseApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { endpointUrl: params.endpointUrl }
const body: Record<string, unknown> = {}
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
Expand Down
18 changes: 18 additions & 0 deletions apps/editor/src/ui/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -895,6 +912,7 @@ export default function HomeScreen({
open={importOpen}
onCancel={() => setImportOpen(false)}
onImport={handleImport}
onImportJson={handleImportJson}
/>

<UploadFrameworkDialog
Expand Down
104 changes: 104 additions & 0 deletions apps/editor/src/ui/home/ImportFrameworkDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi } from 'vitest'
import '@testing-library/jest-dom'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ImportFrameworkDialog, { type ImportResult } from './ImportFrameworkDialog'

const importResult: ImportResult = { status: 'imported', id: 'doc-1', version: 1 }

describe('ImportFrameworkDialog', () => {
it('imports from a URL by default', async () => {
const onImport = vi.fn().mockResolvedValue(importResult)
const onImportJson = vi.fn()

render(
<ImportFrameworkDialog open onCancel={vi.fn()} onImport={onImport} onImportJson={onImportJson} />,
)

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(
<ImportFrameworkDialog open onCancel={vi.fn()} onImport={onImport} onImportJson={onImportJson} />,
)

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(
<ImportFrameworkDialog open onCancel={vi.fn()} onImport={onImport} onImportJson={onImportJson} />,
)

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(
<ImportFrameworkDialog open onCancel={vi.fn()} onImport={onImport} onImportJson={onImportJson} />,
)

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(
<ImportFrameworkDialog open onCancel={vi.fn()} onImport={onImport} onImportJson={onImportJson} />,
)

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()
})
})
Loading
Loading