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
802 changes: 668 additions & 134 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"html2pdf.js": "^0.14.0",
"immer": "^10.1.1",
"jest-canvas-mock": "^2.5.2",
"jszip": "^3.10.1",
"lz-string": "^1.5.0",
"monaco-editor": "^0.50.0",
"node-stdlib-browser": "^1.2.0",
Expand Down
44 changes: 43 additions & 1 deletion src/components/PlaygroundSidebar.tsx
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";
Expand All @@ -12,6 +12,8 @@ import tour from "./Tour";
import "../styles/components/PlaygroundSidebar.css";

const PlaygroundSidebar = () => {
const fileInputRef = React.useRef<HTMLInputElement>(null);

const {
isEditorsVisible,
isPreviewVisible,
Expand All @@ -22,6 +24,8 @@ const PlaygroundSidebar = () => {
setProblemPanelVisible,
setAIChatOpen,
generateShareableLink,
exportTemplate,
importTemplate,
setSettingsOpen,
} = useAppStore((state) => ({
isEditorsVisible: state.isEditorsVisible,
Expand All @@ -33,6 +37,8 @@ const PlaygroundSidebar = () => {
setProblemPanelVisible: state.setProblemPanelVisible,
setAIChatOpen: state.setAIChatOpen,
generateShareableLink: state.generateShareableLink,
exportTemplate: state.exportTemplate,
importTemplate: state.importTemplate,
setSettingsOpen: state.setSettingsOpen,
}));

Expand All @@ -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();
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Export and Import buttons don't provide user feedback on success. Following the pattern used in handleShare (which shows message.success('Link copied to clipboard!')), consider adding success feedback handlers in PlaygroundSidebar. For example:

  • Export: "Template exported successfully!"
  • Import: "Template imported successfully!"

This helps users confirm their actions completed as expected, especially since export happens silently (browser download) and import makes significant state changes.

Suggested change
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!");
}

Copilot uses AI. Check for mistakes.
},
{
title: "Share",
icon: FiShare2,
Expand Down Expand Up @@ -196,6 +231,13 @@ const PlaygroundSidebar = () => {
</Tooltip>
))}
</nav>
<input
ref={fileInputRef}
type="file"
accept=".cta"
onChange={(e) => void handleFileChange(e)}
style={{ display: 'none' }}
/>
Comment on lines +234 to +240
Copy link

Copilot AI Feb 24, 2026

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:

  1. Export button triggers a download
  2. Import button opens file picker and successfully loads a template
  3. 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.

Copilot uses AI. Check for mistakes.
</aside>,
<SettingsModal key="settings-modal" />
];
Expand Down
41 changes: 41 additions & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { transform } from "@accordproject/markdown-transform";
import { SAMPLES, Sample } from "../samples";
import * as playground from "../samples/playground";
import { compress, decompress } from "../utils/compression/compression";
import { createArchive, extractArchive, downloadBlob } from '../utils/archive/archive';
import { AIConfig, ChatState, KeyProtectionLevel } from '../types/components/AIAssistant.types';

interface AppState {
Expand Down Expand Up @@ -39,6 +40,8 @@ interface AppState {
init: () => Promise<void>;
loadSample: (name: string) => Promise<void>;
generateShareableLink: () => string;
exportTemplate: () => Promise<void>;
importTemplate: (file: File) => Promise<void>;
loadFromLink: (compressedData: string) => Promise<void>;
toggleDarkMode: () => void;
setAIConfigOpen: (visible: boolean) => void;
Expand Down Expand Up @@ -329,6 +332,44 @@ const useAppStore = create<AppState>()(
});
return `${window.location.origin}/#data=${compressedData}`;
},
exportTemplate: async () => {
const { sampleName, modelCto, templateMarkdown, data } = get();
try {
const blob = await createArchive(sampleName, modelCto, templateMarkdown, data);
const filename = `${sampleName.toLowerCase().replace(/\s+/g, '-')}.cta`;
downloadBlob(blob, filename);
} catch (error) {
console.error('Export failed:', error);
set(() => ({
error: 'Failed to export template: ' + (error instanceof Error ? error.message : 'Unknown error'),
isProblemPanelVisible: true,
}));
}
},
importTemplate: async (file: File) => {
try {
const { name, model, template, data } = await extractArchive(file);
set(() => ({
sampleName: name,
templateMarkdown: template,
editorValue: template,
modelCto: model,
editorModelCto: model,
data: data,
editorAgreementData: data,
agreementHtml: '',
error: undefined,
}));
await get().rebuild();
} catch (error) {
console.error('Import failed:', error);
set(() => ({
error: 'Failed to import template: ' + (error instanceof Error ? error.message : 'Unknown error'),
isProblemPanelVisible: true,
}));
throw error;
}
},
loadFromLink: async (compressedData: string) => {
try {
const { templateMarkdown, modelCto, data, agreementHtml } = decompress(compressedData);
Expand Down
4 changes: 4 additions & 0 deletions src/tests/components/PlaygroundSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ vi.mock("../../store/store", () => ({
setAIChatOpen: vi.fn(),
setSettingsOpen: vi.fn(),
generateShareableLink: vi.fn(() => "https://example.com"),
exportTemplate: vi.fn(),
importTemplate: vi.fn(),
}),
}));

Expand Down Expand Up @@ -51,6 +53,8 @@ describe("PlaygroundSidebar", () => {
it("renders all bottom navigation items", () => {
renderSidebar();

expect(screen.getByRole("button", { name: /Export/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Import/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Share/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Start Tour/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Settings/i })).toBeInTheDocument();
Expand Down
76 changes: 76 additions & 0 deletions src/tests/store/importExport.test.tsx
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);
});
});
});
94 changes: 94 additions & 0 deletions src/tests/utils/archive.test.ts
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');
});
});
});
Loading