From 8d2236c7844117e727ffe03d1f294656c7be1ad7 Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Tue, 23 Jun 2026 17:28:12 -0400 Subject: [PATCH] Add code generation project import Add an "Import Project" action to the code generation Projects tab that loads projects from a JSON file. Supports a single project object, a top-level array, or a { projects: [...] } wrapper. Imported projects are normalized and validated in the project CRUD service: required fields are checked, project/template IDs are regenerated on collision, and imports are forced to non-example. Model generation now also resolves templates owned by projects, and example projects load locally again. Add service- and hook-level tests covering import success paths, ID de-duplication, field defaults, parsing branches, and validation errors. --- .../hooks/useProjectManagement-import.test.ts | 112 ++++++++++++++++ .../services/codegen-project-import.test.ts | 124 ++++++++++++++++++ .../codegeneration/CodeGenerator.tsx | 1 + .../codegeneration/components/ProjectsTab.tsx | 27 +++- .../hooks/useProjectManagement.ts | 30 +++++ .../codegen-example-data.service.ts | 16 ++- .../codegen-model-generation.service.ts | 6 +- .../codegen-project-crud.service.ts | 79 +++++++++++ .../codegeneration/codegeneration.service.ts | 4 + 9 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__tests__/hooks/useProjectManagement-import.test.ts create mode 100644 frontend/src/__tests__/services/codegen-project-import.test.ts diff --git a/frontend/src/__tests__/hooks/useProjectManagement-import.test.ts b/frontend/src/__tests__/hooks/useProjectManagement-import.test.ts new file mode 100644 index 0000000..5da1621 --- /dev/null +++ b/frontend/src/__tests__/hooks/useProjectManagement-import.test.ts @@ -0,0 +1,112 @@ +import { act, renderHook } from '@testing-library/react'; +import { useProjectManagement } from '../../components/codegeneration/hooks/useProjectManagement'; +import { codeGenerationService } from '../../services/codegeneration'; + +jest.mock('../../services/codegeneration', () => ({ + codeGenerationService: { + importProject: jest.fn(), + getAllProjects: jest.fn(), + }, +})); + +const importProject = codeGenerationService.importProject as jest.Mock; +const getAllProjects = codeGenerationService.getAllProjects as jest.Mock; + +const makeFile = (content: string): File => ({ + text: () => Promise.resolve(content), +} as unknown as File); + +const project = (id: string, isExample = false) => ({ + id, + name: `Project ${id}`, + description: '', + targetMetamodelId: 'metamodel-1', + templates: [], + createdAt: 1, + updatedAt: 1, + isExample, +}); + +describe('useProjectManagement.handleImportProject', () => { + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + importProject.mockReset(); + getAllProjects.mockReset(); + alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + it('imports a single project object and selects it', async () => { + importProject.mockReturnValue(project('imported-1')); + getAllProjects.mockReturnValue([project('imported-1'), project('ex-1', true)]); + + const { result } = renderHook(() => useProjectManagement()); + await act(async () => { + await result.current.handleImportProject(makeFile(JSON.stringify(project('imported-1')))); + }); + + expect(importProject).toHaveBeenCalledTimes(1); + expect(result.current.selectedProject).toBe('imported-1'); + expect(result.current.projects.map(p => p.id)).toEqual(['imported-1']); + expect(result.current.exampleProjects.map(p => p.id)).toEqual(['ex-1']); + }); + + it('imports a top-level array of projects', async () => { + importProject.mockImplementation((p: any) => project(p.id)); + getAllProjects.mockReturnValue([]); + + const { result } = renderHook(() => useProjectManagement()); + await act(async () => { + await result.current.handleImportProject( + makeFile(JSON.stringify([project('a'), project('b')])) + ); + }); + + expect(importProject).toHaveBeenCalledTimes(2); + expect(result.current.selectedProject).toBe('a'); + }); + + it('imports from a { projects: [...] } wrapper', async () => { + importProject.mockImplementation((p: any) => project(p.id)); + getAllProjects.mockReturnValue([]); + + const { result } = renderHook(() => useProjectManagement()); + await act(async () => { + await result.current.handleImportProject( + makeFile(JSON.stringify({ projects: [project('x'), project('y')] })) + ); + }); + + expect(importProject).toHaveBeenCalledTimes(2); + expect(result.current.selectedProject).toBe('x'); + }); + + it('alerts and does not throw on invalid JSON', async () => { + const { result } = renderHook(() => useProjectManagement()); + await act(async () => { + await result.current.handleImportProject(makeFile('not json')); + }); + + expect(importProject).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalled(); + }); + + it('alerts when importProject rejects the payload', async () => { + importProject.mockImplementation(() => { + throw new Error('Imported project is missing a valid name'); + }); + + const { result } = renderHook(() => useProjectManagement()); + await act(async () => { + await result.current.handleImportProject(makeFile(JSON.stringify({ bad: true }))); + }); + + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining('Imported project is missing a valid name') + ); + }); +}); diff --git a/frontend/src/__tests__/services/codegen-project-import.test.ts b/frontend/src/__tests__/services/codegen-project-import.test.ts new file mode 100644 index 0000000..c1950b2 --- /dev/null +++ b/frontend/src/__tests__/services/codegen-project-import.test.ts @@ -0,0 +1,124 @@ +import { CodegenProjectCrudService } from '../../services/codegeneration/codegen-project-crud.service'; +import { codegenApiSyncService } from '../../services/codegeneration/codegen-api-sync.service'; + +jest.mock('../../services/codegeneration/codegen-api-sync.service', () => ({ + codegenApiSyncService: { + syncProjectToAPI: jest.fn().mockResolvedValue(undefined), + }, +})); + +const syncProjectToAPI = codegenApiSyncService.syncProjectToAPI as any; + +const validTemplate = () => ({ + id: 'template-1', + name: 'Entity Template', + language: 'java' as const, + templateContent: 'class {{name}} {}', + targetMetamodelId: 'metamodel-1', + outputPattern: '{{name}}.java', +}); + +const validProject = () => ({ + id: 'project-1', + name: 'Demo Project', + description: 'A demo', + targetMetamodelId: 'metamodel-1', + templates: [validTemplate()], + createdAt: 1000, + updatedAt: 2000, + isExample: true, +}); + +describe('CodegenProjectCrudService.importProject', () => { + let service: CodegenProjectCrudService; + + beforeEach(() => { + service = new CodegenProjectCrudService(); + syncProjectToAPI.mockClear(); + }); + + it('imports a valid project and syncs it to the API', () => { + const imported = service.importProject(validProject()); + + expect(imported.id).toBe('project-1'); + expect(imported.name).toBe('Demo Project'); + expect(imported.description).toBe('A demo'); + expect(imported.targetMetamodelId).toBe('metamodel-1'); + expect(imported.templates).toHaveLength(1); + expect(imported.templates[0].id).toBe('template-1'); + expect(imported.createdAt).toBe(1000); + expect(service.getAllProjects()).toHaveLength(1); + expect(syncProjectToAPI).toHaveBeenCalledWith(imported); + }); + + it('forces imported projects to be non-example', () => { + const imported = service.importProject(validProject()); + expect(imported.isExample).toBe(false); + }); + + it('assigns a fresh project id when the id collides with an existing project', () => { + service.importProject(validProject()); + const second = service.importProject(validProject()); + + expect(second.id).not.toBe('project-1'); + expect(service.getAllProjects()).toHaveLength(2); + }); + + it('assigns a fresh template id when a template id collides with an existing one', () => { + service.importProject(validProject()); + const second = service.importProject({ + ...validProject(), + id: 'project-2', + templates: [validTemplate()], + }); + + expect(second.templates[0].id).not.toBe('template-1'); + }); + + it('assigns a fresh template id when ids collide within the same import', () => { + const imported = service.importProject({ + ...validProject(), + templates: [validTemplate(), validTemplate()], + }); + + expect(imported.templates[0].id).not.toBe(imported.templates[1].id); + }); + + it('falls back to the project metamodel id when a template omits its own', () => { + const { targetMetamodelId, ...templateWithoutMetamodel } = validTemplate(); + const imported = service.importProject({ + ...validProject(), + templates: [templateWithoutMetamodel], + }); + + expect(imported.templates[0].targetMetamodelId).toBe('metamodel-1'); + }); + + it('defaults missing optional fields', () => { + const { description, createdAt, ...rest } = validProject(); + const imported = service.importProject(rest); + + expect(imported.description).toBe(''); + expect(typeof imported.createdAt).toBe('number'); + }); + + describe('validation', () => { + const cases: Array<[string, unknown]> = [ + ['non-object input', 'not-a-project'], + ['null input', null], + ['missing name', { ...validProject(), name: undefined }], + ['missing targetMetamodelId', { ...validProject(), targetMetamodelId: undefined }], + ['non-array templates', { ...validProject(), templates: {} }], + ['template missing name', { ...validProject(), templates: [{ ...validTemplate(), name: undefined }] }], + ['template invalid language', { ...validProject(), templates: [{ ...validTemplate(), language: 'ruby' }] }], + ['template missing content', { ...validProject(), templates: [{ ...validTemplate(), templateContent: undefined }] }], + ['template missing outputPattern', { ...validProject(), templates: [{ ...validTemplate(), outputPattern: undefined }] }], + ]; + + it.each(cases)('rejects %s', (_label, input) => { + expect(() => service.importProject(input)).toThrow(); + expect(service.getAllProjects()).toHaveLength(0); + expect(syncProjectToAPI).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/codegeneration/CodeGenerator.tsx b/frontend/src/components/codegeneration/CodeGenerator.tsx index 01b6aff..56d28d1 100644 --- a/frontend/src/components/codegeneration/CodeGenerator.tsx +++ b/frontend/src/components/codegeneration/CodeGenerator.tsx @@ -264,6 +264,7 @@ const CodeGenerator: React.FC = ({ modelId }) => { templateManagement.setActiveTemplateTab(0); projectManagement.setIsProjectDialogOpen(true); }} + onImportProject={projectManagement.handleImportProject} canCreate={canCreate} canDelete={canDelete} canShare={canShare} diff --git a/frontend/src/components/codegeneration/components/ProjectsTab.tsx b/frontend/src/components/codegeneration/components/ProjectsTab.tsx index 7cb650e..d9fbaff 100644 --- a/frontend/src/components/codegeneration/components/ProjectsTab.tsx +++ b/frontend/src/components/codegeneration/components/ProjectsTab.tsx @@ -16,6 +16,7 @@ import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ShareIcon from '@mui/icons-material/Share'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; import { CodeGenerationProject } from '../../../models/types'; import { metamodelService } from '../../../services/metamodel'; import { getParentGroupSurfaceColor, groupByParent } from '../../../services/common/grouping.service'; @@ -26,6 +27,7 @@ interface ProjectsTabProps { onDelete: (projectId: string) => void; onShare: (project: CodeGenerationProject) => void; onNewProject: () => void; + onImportProject: (file: File) => void; canCreate: boolean; canDelete: boolean; canShare: boolean; @@ -40,10 +42,13 @@ export const ProjectsTab: React.FC = ({ onDelete, onShare, onNewProject, + onImportProject, canCreate, canDelete, canShare }) => { + const fileInputRef = React.useRef(null); + const getMetamodelName = (metamodelId: string): string => ( metamodelService.getMetamodelById(metamodelId)?.name || 'Unknown' ); @@ -115,7 +120,27 @@ export const ProjectsTab: React.FC = ({ {canCreate && ( - + + { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file) { + onImportProject(file); + } + }} + /> +