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
112 changes: 112 additions & 0 deletions frontend/src/__tests__/hooks/useProjectManagement-import.test.ts
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
124 changes: 124 additions & 0 deletions frontend/src/__tests__/services/codegen-project-import.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
1 change: 1 addition & 0 deletions frontend/src/components/codegeneration/CodeGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ const CodeGenerator: React.FC<CodeGeneratorProps> = ({ modelId }) => {
templateManagement.setActiveTemplateTab(0);
projectManagement.setIsProjectDialogOpen(true);
}}
onImportProject={projectManagement.handleImportProject}
canCreate={canCreate}
canDelete={canDelete}
canShare={canShare}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -40,10 +42,13 @@ export const ProjectsTab: React.FC<ProjectsTabProps> = ({
onDelete,
onShare,
onNewProject,
onImportProject,
canCreate,
canDelete,
canShare
}) => {
const fileInputRef = React.useRef<HTMLInputElement | null>(null);

const getMetamodelName = (metamodelId: string): string => (
metamodelService.getMetamodelById(metamodelId)?.name || 'Unknown'
);
Expand Down Expand Up @@ -115,7 +120,27 @@ export const ProjectsTab: React.FC<ProjectsTabProps> = ({
</List>

{canCreate && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) {
onImportProject(file);
}
}}
/>
<Button
variant="outlined"
startIcon={<UploadFileIcon />}
onClick={() => fileInputRef.current?.click()}
>
Import Project
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface UseProjectManagementReturn {
handleProjectChange: (event: SelectChangeEvent<string>) => void;
handleCreateProject: (templates: ProjectTemplate[]) => void;
handleUpdateProject: (templates: ProjectTemplate[]) => void;
handleImportProject: (file: File) => Promise<void>;
handleEditProject: (project: CodeGenerationProject) => ProjectTemplate[];
handleDeleteProject: (projectId: string) => void;
resetProjectForm: () => ProjectTemplate[];
Expand Down Expand Up @@ -150,6 +151,34 @@ export const useProjectManagement = (): UseProjectManagementReturn => {
setSelectedProjectForEditing(null);
}, [selectedProjectForEditing, projectName, projectDescription, projectTarget]);

/**
* Import a project from a JSON file.
*/
const handleImportProject = useCallback(async (file: File) => {
try {
const rawContent = await file.text();
const parsedContent = JSON.parse(rawContent);
const projectPayloads: unknown[] = Array.isArray(parsedContent)
? parsedContent
: Array.isArray(parsedContent.projects)
? parsedContent.projects
: [parsedContent];

const importedProjects = projectPayloads.map((project: unknown) => codeGenerationService.importProject(project));

const allProjects = codeGenerationService.getAllProjects();
setProjects(allProjects.filter(p => !p.isExample));
setExampleProjects(allProjects.filter(p => p.isExample));

if (importedProjects.length > 0) {
setSelectedProject(importedProjects[0].id);
}
} catch (error) {
console.error('Error importing code generation project:', error);
alert(`Error importing project: ${error instanceof Error ? error.message : String(error)}`);
}
}, []);

/**
* Edit a project (opens dialog with project data)
* Returns templates to be set in template management hook
Expand Down Expand Up @@ -246,6 +275,7 @@ export const useProjectManagement = (): UseProjectManagementReturn => {
handleProjectChange,
handleCreateProject,
handleUpdateProject,
handleImportProject,
handleEditProject,
handleDeleteProject,
resetProjectForm
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@


import { exampleDataService } from '../metamodel/exampleData.service';
import { codegenProjectCrudService } from './codegen-project-crud.service';

/**
* Service for loading example templates and projects
* Note: Example data should be seeded through backend. This is kept for backward compatibility.
Expand All @@ -17,8 +20,17 @@ export class CodegenExampleDataService {
* Load example projects (deprecated - should use backend seed)
*/
loadExampleProjects(): void {
console.log('Example data projects should be loaded via backend seed command');
// Example data loading moved to backend seeding
const existingProjects = codegenProjectCrudService.getAllProjects();
const exampleProjects = exampleDataService.getExampleProjects();
const projectsToAdd = exampleProjects.filter(
exampleProject => !existingProjects.some(project => project.id === exampleProject.id)
);

if (projectsToAdd.length === 0) {
return;
}

codegenProjectCrudService.setProjects([...existingProjects, ...projectsToAdd]);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export class CodegenModelGenerationService {
generateCodeFromModel(modelId: string, templateId: string): CodeGenerationResult[] {
console.log('Generating code with template:', templateId, 'for model:', modelId);

const template = codegenTemplateCrudService.getTemplateById(templateId);
const template = codegenTemplateCrudService.getTemplateById(templateId)
?? codegenProjectCrudService
.getAllProjects()
.flatMap(project => project.templates)
.find(projectTemplate => projectTemplate.id === templateId);
if (!template) {
throw new Error('Template not found');
}
Expand Down
Loading
Loading