-
-
Notifications
You must be signed in to change notification settings - Fork 213
Feat/template import export #562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,7 @@ | ||||||||||||||||||||||||||||||||||||||
| import React from "react"; | ||||||||||||||||||||||||||||||||||||||
| import { IoCodeSlash } from "react-icons/io5"; | ||||||||||||||||||||||||||||||||||||||
| import { VscOutput } from "react-icons/vsc"; | ||||||||||||||||||||||||||||||||||||||
| import { FiTerminal, FiShare2, FiSettings } from "react-icons/fi"; | ||||||||||||||||||||||||||||||||||||||
| import { FiTerminal, FiShare2, FiSettings, FiDownload, FiUpload } from "react-icons/fi"; | ||||||||||||||||||||||||||||||||||||||
| import { FaCirclePlay } from "react-icons/fa6"; | ||||||||||||||||||||||||||||||||||||||
| import { IoChatbubbleEllipsesOutline } from "react-icons/io5"; | ||||||||||||||||||||||||||||||||||||||
| import useAppStore from "../store/store"; | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -12,6 +12,8 @@ import tour from "./Tour"; | |||||||||||||||||||||||||||||||||||||
| import "../styles/components/PlaygroundSidebar.css"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const PlaygroundSidebar = () => { | ||||||||||||||||||||||||||||||||||||||
| const fileInputRef = React.useRef<HTMLInputElement>(null); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||||||||||
| isEditorsVisible, | ||||||||||||||||||||||||||||||||||||||
| isPreviewVisible, | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -22,6 +24,8 @@ const PlaygroundSidebar = () => { | |||||||||||||||||||||||||||||||||||||
| setProblemPanelVisible, | ||||||||||||||||||||||||||||||||||||||
| setAIChatOpen, | ||||||||||||||||||||||||||||||||||||||
| generateShareableLink, | ||||||||||||||||||||||||||||||||||||||
| exportTemplate, | ||||||||||||||||||||||||||||||||||||||
| importTemplate, | ||||||||||||||||||||||||||||||||||||||
| setSettingsOpen, | ||||||||||||||||||||||||||||||||||||||
| } = useAppStore((state) => ({ | ||||||||||||||||||||||||||||||||||||||
| isEditorsVisible: state.isEditorsVisible, | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -33,6 +37,8 @@ const PlaygroundSidebar = () => { | |||||||||||||||||||||||||||||||||||||
| setProblemPanelVisible: state.setProblemPanelVisible, | ||||||||||||||||||||||||||||||||||||||
| setAIChatOpen: state.setAIChatOpen, | ||||||||||||||||||||||||||||||||||||||
| generateShareableLink: state.generateShareableLink, | ||||||||||||||||||||||||||||||||||||||
| exportTemplate: state.exportTemplate, | ||||||||||||||||||||||||||||||||||||||
| importTemplate: state.importTemplate, | ||||||||||||||||||||||||||||||||||||||
| setSettingsOpen: state.setSettingsOpen, | ||||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
@@ -51,6 +57,25 @@ const PlaygroundSidebar = () => { | |||||||||||||||||||||||||||||||||||||
| setSettingsOpen(true); | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const handleImportClick = () => { | ||||||||||||||||||||||||||||||||||||||
| fileInputRef.current?.click(); | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||||||||||||||||||||||||||||||||||
| const file = e.target.files?.[0]; | ||||||||||||||||||||||||||||||||||||||
| if (file) { | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| await importTemplate(file); | ||||||||||||||||||||||||||||||||||||||
| void message.success('Template imported successfully!'); | ||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||
| console.error('Import failed:', error); | ||||||||||||||||||||||||||||||||||||||
| void message.error('Failed to import template'); | ||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||
| e.target.value = ''; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const handleStartTour = async () => { | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| await tour.start(); | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -136,6 +161,16 @@ const PlaygroundSidebar = () => { | |||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const navBottom: NavBottomItem[] = [ | ||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||
| title: "Export", | ||||||||||||||||||||||||||||||||||||||
| icon: FiDownload, | ||||||||||||||||||||||||||||||||||||||
| onClick: () => void exportTemplate() | ||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||
| title: "Import", | ||||||||||||||||||||||||||||||||||||||
| icon: FiUpload, | ||||||||||||||||||||||||||||||||||||||
| onClick: handleImportClick | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+167
to
+172
|
||||||||||||||||||||||||||||||||||||||
| onClick: () => void exportTemplate() | |
| }, | |
| { | |
| title: "Import", | |
| icon: FiUpload, | |
| onClick: handleImportClick | |
| onClick: () => { | |
| void exportTemplate(); | |
| message.success("Template exported successfully!"); | |
| } | |
| }, | |
| { | |
| title: "Import", | |
| icon: FiUpload, | |
| onClick: () => { | |
| void handleImportClick(); | |
| message.success("Template imported successfully!"); | |
| } |
Shubh-Raj marked this conversation as resolved.
Show resolved
Hide resolved
Copilot
AI
Feb 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider adding end-to-end tests for the export/import workflow. Following the pattern in e2e/template-workflow.spec.ts, tests should verify:
- Export button triggers a download
- Import button opens file picker and successfully loads a template
- Round-trip workflow: export a template, then import it back and verify data preservation
While the unit tests cover the utilities well, E2E tests would ensure the complete user workflow functions correctly, especially the file picker integration and browser download behavior.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { vi, describe, it, expect, beforeEach } from 'vitest'; | ||
| import useAppStore from '../../store/store'; | ||
|
|
||
| vi.mock('../../utils/archive/archive', () => ({ | ||
| createArchive: vi.fn(), | ||
| extractArchive: vi.fn(), | ||
| downloadBlob: vi.fn(), | ||
| })); | ||
|
|
||
| import { createArchive, extractArchive, downloadBlob } from '../../utils/archive/archive'; | ||
|
|
||
| describe('useAppStore - import/export', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('exportTemplate', () => { | ||
| it('should create archive and trigger download', async () => { | ||
| const mockBlob = new Blob(['test']); | ||
| vi.mocked(createArchive).mockResolvedValue(mockBlob); | ||
|
|
||
| useAppStore.setState({ | ||
| sampleName: 'Test', | ||
| modelCto: 'model', | ||
| templateMarkdown: 'template', | ||
| data: '{}', | ||
| }); | ||
|
|
||
| await useAppStore.getState().exportTemplate(); | ||
|
|
||
| expect(createArchive).toHaveBeenCalledWith('Test', 'model', 'template', '{}'); | ||
| expect(downloadBlob).toHaveBeenCalledWith(mockBlob, 'test.cta'); | ||
| }); | ||
|
|
||
| it('should set error on failure', async () => { | ||
| vi.mocked(createArchive).mockRejectedValue(new Error('zip failed')); | ||
|
|
||
| await useAppStore.getState().exportTemplate(); | ||
|
|
||
| const state = useAppStore.getState(); | ||
| expect(state.error).toContain('Failed to export template'); | ||
| expect(state.isProblemPanelVisible).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('importTemplate', () => { | ||
| it('should extract archive and update store state', async () => { | ||
| vi.mocked(extractArchive).mockResolvedValue({ | ||
| name: 'Imported', | ||
| model: 'new model', | ||
| template: 'new template', | ||
| data: '{"key":"value"}', | ||
| }); | ||
|
|
||
| const file = new File(['fake'], 'test.cta'); | ||
| await useAppStore.getState().importTemplate(file); | ||
|
|
||
| const state = useAppStore.getState(); | ||
| expect(state.sampleName).toBe('Imported'); | ||
| expect(state.modelCto).toBe('new model'); | ||
| expect(state.templateMarkdown).toBe('new template'); | ||
| expect(state.data).toBe('{"key":"value"}'); | ||
| }); | ||
|
|
||
| it('should set error on failure', async () => { | ||
| vi.mocked(extractArchive).mockRejectedValue(new Error('invalid cta')); | ||
|
|
||
| const file = new File(['bad'], 'bad.cta'); | ||
| await expect(useAppStore.getState().importTemplate(file)).rejects.toThrow('invalid cta'); | ||
|
|
||
| const state = useAppStore.getState(); | ||
| expect(state.error).toContain('Failed to import template'); | ||
| expect(state.isProblemPanelVisible).toBe(true); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { createArchive, extractArchive } from '../../utils/archive/archive'; | ||
|
|
||
| describe('Archive Utilities', () => { | ||
| const mockData = { | ||
| name: 'Test Template', | ||
| model: 'namespace test@1.0.0\nconcept Data { o String value }', | ||
| template: '# Hello {{value}}', | ||
| data: JSON.stringify({ $class: 'test@1.0.0.Data', value: 'World' }), | ||
| }; | ||
|
|
||
| describe('createArchive', () => { | ||
| it('should create a valid ZIP blob', async () => { | ||
| const blob = await createArchive( | ||
| mockData.name, | ||
| mockData.model, | ||
| mockData.template, | ||
| mockData.data | ||
| ); | ||
| expect(blob).toBeInstanceOf(Blob); | ||
| expect(blob.size).toBeGreaterThan(0); | ||
| }); | ||
| }); | ||
|
|
||
| describe('extractArchive', () => { | ||
| it('should extract template data from archive', async () => { | ||
| const blob = await createArchive( | ||
| mockData.name, | ||
| mockData.model, | ||
| mockData.template, | ||
| mockData.data | ||
| ); | ||
| const file = new File([blob], 'test.cta', { type: 'application/zip' }); | ||
| const extracted = await extractArchive(file); | ||
|
|
||
| expect(extracted.model).toBe(mockData.model); | ||
| expect(extracted.template).toBe(mockData.template); | ||
| expect(extracted.data).toBe(mockData.data); | ||
| }); | ||
|
|
||
| it('should extract human-readable name from description field', async () => { | ||
| const blob = await createArchive( | ||
| mockData.name, | ||
| mockData.model, | ||
| mockData.template, | ||
| mockData.data | ||
| ); | ||
| const file = new File([blob], 'test.cta'); | ||
| const extracted = await extractArchive(file); | ||
| expect(extracted.name).toBe('Test Template'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('round-trip', () => { | ||
| it('should preserve data through export/import cycle', async () => { | ||
| const blob = await createArchive( | ||
| mockData.name, | ||
| mockData.model, | ||
| mockData.template, | ||
| mockData.data | ||
| ); | ||
| const file = new File([blob], 'roundtrip.cta'); | ||
| const result = await extractArchive(file); | ||
|
|
||
| expect(result.name).toBe(mockData.name); | ||
| expect(result.model).toBe(mockData.model); | ||
| expect(result.template).toBe(mockData.template); | ||
| expect(result.data).toBe(mockData.data); | ||
| }); | ||
| }); | ||
| describe('error handling', () => { | ||
| it('should throw when archive is missing model file', async () => { | ||
| const JSZip = (await import('jszip')).default; | ||
| const zip = new JSZip(); | ||
| zip.file('package.json', '{"name":"test"}'); | ||
| zip.file('text/grammar.tem.md', 'template'); | ||
| const blob = await zip.generateAsync({ type: 'blob' }); | ||
| const file = new File([blob], 'bad.cta'); | ||
|
|
||
| await expect(extractArchive(file)).rejects.toThrow('Archive missing model .cto file'); | ||
| }); | ||
|
|
||
| it('should throw when archive is missing grammar file', async () => { | ||
| const JSZip = (await import('jszip')).default; | ||
| const zip = new JSZip(); | ||
| zip.file('package.json', '{"name":"test"}'); | ||
| zip.file('model/model.cto', 'namespace test'); | ||
| const blob = await zip.generateAsync({ type: 'blob' }); | ||
| const file = new File([blob], 'bad.cta'); | ||
|
|
||
| await expect(extractArchive(file)).rejects.toThrow('Archive missing text/grammar.tem.md'); | ||
| }); | ||
| }); | ||
| }); | ||
Shubh-Raj marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Uh oh!
There was an error while loading. Please reload this page.