Complete WB-007 and WB-008: Basic Renderer and Save/Load with Auto-save
This commit is contained in:
parent
a8073c50e5
commit
120b7285dc
23 changed files with 977 additions and 47 deletions
|
|
@ -9,6 +9,10 @@ export const Container: React.FC<ContainerProps> = ({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
return <div className={`container ${className}`}>{children}</div>;
|
return (
|
||||||
|
<div data-testid="container" className={`container ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ export interface SpacerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Spacer: React.FC<SpacerProps> = ({ height = 20, width = 0 }) => {
|
export const Spacer: React.FC<SpacerProps> = ({ height = 20, width = 0 }) => {
|
||||||
return <div style={{ height, width, minHeight: height, minWidth: width }} />;
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="spacer"
|
||||||
|
style={{ height, width, minHeight: height, minWidth: width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import { Toolbar } from './Toolbar';
|
import { Toolbar } from './Toolbar';
|
||||||
import { ComponentPalette } from './ComponentPalette';
|
import { ComponentPalette } from './ComponentPalette';
|
||||||
import { PropertyPanel } from './PropertyPanel';
|
import { PropertyPanel } from './PropertyPanel';
|
||||||
import { Canvas } from './Canvas';
|
import { Canvas } from './Canvas';
|
||||||
|
import { NotificationToast } from './NotificationToast';
|
||||||
import { DragItem } from '../hooks/useDragDrop';
|
import { DragItem } from '../hooks/useDragDrop';
|
||||||
import { usePageStorage } from '../hooks/usePageStorage';
|
import { usePageStorage } from '../hooks/usePageStorage';
|
||||||
|
import { useAutoSave } from '../hooks/useAutoSave';
|
||||||
|
import { useUnsavedChanges } from '../hooks/useUnsavedChanges';
|
||||||
|
import { useNotifications } from '../hooks/useNotifications';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
|
|
@ -14,37 +18,106 @@ interface EditorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Editor: React.FC<EditorProps> = ({ pageId }) => {
|
export const Editor: React.FC<EditorProps> = ({ pageId }) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [selectedComponent, setSelectedComponent] = useState<any>(null);
|
const [selectedComponent, setSelectedComponent] = useState<any>(null);
|
||||||
const [canvasComponents, setCanvasComponents] = useState<any[]>([]);
|
const [canvasComponents, setCanvasComponents] = useState<any[]>([]);
|
||||||
|
const [initialComponents, setInitialComponents] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
|
||||||
const { savePage, loadPage, isLoading: storageLoading } = usePageStorage();
|
const { savePage, loadPage, isLoading: storageLoading } = usePageStorage();
|
||||||
|
const notifications = useNotifications();
|
||||||
|
|
||||||
|
// Track if there are unsaved changes
|
||||||
|
const hasUnsavedChanges =
|
||||||
|
JSON.stringify(canvasComponents) !== JSON.stringify(initialComponents);
|
||||||
|
|
||||||
|
// Auto-save functionality
|
||||||
|
const { triggerSave } = useAutoSave({
|
||||||
|
onSave: async () => {
|
||||||
|
if (!pageId || !hasUnsavedChanges) return;
|
||||||
|
await handleSave(false); // false = don't show notification for auto-save
|
||||||
|
},
|
||||||
|
debounceMs: 2000,
|
||||||
|
enabled: !!pageId && hasUnsavedChanges,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsaved changes warning
|
||||||
|
useUnsavedChanges({
|
||||||
|
hasChanges: hasUnsavedChanges,
|
||||||
|
onBeforeUnload: () => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
return window.confirm(
|
||||||
|
'You have unsaved changes. Are you sure you want to leave?'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load page on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageId && !hasLoadedRef.current) {
|
||||||
|
setIsLoading(true);
|
||||||
|
loadPage(pageId)
|
||||||
|
.then((config) => {
|
||||||
|
if (config?.components) {
|
||||||
|
setCanvasComponents(config.components);
|
||||||
|
setInitialComponents(config.components);
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
notifications.error(`Failed to load page: ${error.message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pageId, loadPage, notifications]);
|
||||||
|
|
||||||
|
// Trigger auto-save when components change
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasLoadedRef.current && hasUnsavedChanges) {
|
||||||
|
triggerSave();
|
||||||
|
}
|
||||||
|
}, [canvasComponents, triggerSave, hasUnsavedChanges]);
|
||||||
|
|
||||||
|
const handleSave = async (showNotification = true) => {
|
||||||
|
if (!pageId) {
|
||||||
|
notifications.warning('No page ID provided');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!pageId) return;
|
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
await savePage(pageId, { components: canvasComponents });
|
await savePage(pageId, { components: canvasComponents });
|
||||||
// Show success notification
|
setInitialComponents(canvasComponents);
|
||||||
} catch (error) {
|
if (showNotification) {
|
||||||
// Show error notification
|
notifications.success('Page saved successfully');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
notifications.error(`Failed to save page: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (pageId) {
|
|
||||||
loadPage(pageId).then((config) => {
|
|
||||||
if (config?.components) {
|
|
||||||
setCanvasComponents(config.components);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [pageId, loadPage]);
|
|
||||||
|
|
||||||
const handlePreview = () => {
|
const handlePreview = () => {
|
||||||
// TODO: Implement preview logic
|
if (!pageId) {
|
||||||
|
notifications.warning('No page ID provided');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(`/preview/${pageId}`, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExit = () => {
|
const handleExit = () => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'You have unsaved changes. Are you sure you want to exit?'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,10 +138,11 @@ export const Editor: React.FC<EditorProps> = ({ pageId }) => {
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div data-testid="editor-container" className="editor-container">
|
<div data-testid="editor-container" className="editor-container">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
onSave={handleSave}
|
onSave={() => handleSave(true)}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
isLoading={isLoading || storageLoading}
|
isLoading={isLoading || storageLoading}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
/>
|
/>
|
||||||
<div className="editor-content">
|
<div className="editor-content">
|
||||||
<div data-testid="left-sidebar" className="sidebar left">
|
<div data-testid="left-sidebar" className="sidebar left">
|
||||||
|
|
@ -84,6 +158,15 @@ export const Editor: React.FC<EditorProps> = ({ pageId }) => {
|
||||||
<PropertyPanel selectedComponent={selectedComponent} />
|
<PropertyPanel selectedComponent={selectedComponent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="notifications-container" data-testid="notifications">
|
||||||
|
{notifications.notifications.map((notif) => (
|
||||||
|
<NotificationToast
|
||||||
|
key={notif.id}
|
||||||
|
notification={notif}
|
||||||
|
onClose={() => notifications.removeNotification(notif.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
53
frontend/editor/components/NotificationToast.test.tsx
Normal file
53
frontend/editor/components/NotificationToast.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { NotificationToast } from './NotificationToast';
|
||||||
|
import { Notification } from '../hooks/useNotifications';
|
||||||
|
|
||||||
|
describe('NotificationToast', () => {
|
||||||
|
it('should render success notification', () => {
|
||||||
|
const notification: Notification = {
|
||||||
|
id: '1',
|
||||||
|
type: 'success',
|
||||||
|
message: 'Saved successfully',
|
||||||
|
};
|
||||||
|
const onClose = jest.fn();
|
||||||
|
|
||||||
|
render(<NotificationToast notification={notification} onClose={onClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Saved successfully')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('notification-success')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render error notification', () => {
|
||||||
|
const notification: Notification = {
|
||||||
|
id: '1',
|
||||||
|
type: 'error',
|
||||||
|
message: 'Save failed',
|
||||||
|
};
|
||||||
|
const onClose = jest.fn();
|
||||||
|
|
||||||
|
render(<NotificationToast notification={notification} onClose={onClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Save failed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('notification-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onClose when close button is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const notification: Notification = {
|
||||||
|
id: '1',
|
||||||
|
type: 'info',
|
||||||
|
message: 'Test message',
|
||||||
|
};
|
||||||
|
const onClose = jest.fn();
|
||||||
|
|
||||||
|
render(<NotificationToast notification={notification} onClose={onClose} />);
|
||||||
|
|
||||||
|
const closeButton = screen.getByTestId('notification-close');
|
||||||
|
await user.click(closeButton);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
31
frontend/editor/components/NotificationToast.tsx
Normal file
31
frontend/editor/components/NotificationToast.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Notification } from '../hooks/useNotifications';
|
||||||
|
|
||||||
|
interface NotificationToastProps {
|
||||||
|
notification: Notification;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationToast: React.FC<NotificationToastProps> = ({
|
||||||
|
notification,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`notification-${notification.type}`}
|
||||||
|
className={`notification notification-${notification.type}`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span className="notification-message">{notification.message}</span>
|
||||||
|
<button
|
||||||
|
data-testid="notification-close"
|
||||||
|
className="notification-close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -5,6 +5,7 @@ interface ToolbarProps {
|
||||||
onPreview?: () => void;
|
onPreview?: () => void;
|
||||||
onExit?: () => void;
|
onExit?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
|
|
@ -12,6 +13,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
onPreview,
|
onPreview,
|
||||||
onExit,
|
onExit,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div data-testid="toolbar" className="toolbar">
|
<div data-testid="toolbar" className="toolbar">
|
||||||
|
|
@ -20,8 +22,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="Save page"
|
aria-label="Save page"
|
||||||
|
className={hasUnsavedChanges ? 'has-changes' : ''}
|
||||||
>
|
>
|
||||||
Save
|
Save {hasUnsavedChanges && '*'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
data-testid="preview-btn"
|
data-testid="preview-btn"
|
||||||
|
|
@ -44,6 +47,11 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
Loading...
|
Loading...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{hasUnsavedChanges && !isLoading && (
|
||||||
|
<span data-testid="unsaved-indicator" className="unsaved-indicator">
|
||||||
|
Unsaved changes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { DndProvider } from 'react-dnd';
|
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
||||||
|
|
||||||
|
// Mock DndProvider for tests - components don't need drag-drop in renderer tests
|
||||||
export const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
|
export const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return <DndProvider backend={HTML5Backend}>{children}</DndProvider>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
62
frontend/editor/hooks/useAutoSave.test.ts
Normal file
62
frontend/editor/hooks/useAutoSave.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { useAutoSave } from './useAutoSave';
|
||||||
|
|
||||||
|
describe('useAutoSave hook', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should debounce save calls', async () => {
|
||||||
|
const onSave = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAutoSave({ onSave, debounceMs: 1000 })
|
||||||
|
);
|
||||||
|
|
||||||
|
result.current.triggerSave();
|
||||||
|
result.current.triggerSave();
|
||||||
|
result.current.triggerSave();
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not save when disabled', () => {
|
||||||
|
const onSave = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAutoSave({ onSave, enabled: false })
|
||||||
|
);
|
||||||
|
|
||||||
|
result.current.triggerSave();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track last save time', async () => {
|
||||||
|
const onSave = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAutoSave({ onSave }));
|
||||||
|
|
||||||
|
result.current.triggerSave();
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastSaveTime).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
44
frontend/editor/hooks/useAutoSave.ts
Normal file
44
frontend/editor/hooks/useAutoSave.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface UseAutoSaveOptions {
|
||||||
|
onSave: () => Promise<void>;
|
||||||
|
debounceMs?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAutoSave = ({
|
||||||
|
onSave,
|
||||||
|
debounceMs = 2000,
|
||||||
|
enabled = true,
|
||||||
|
}: UseAutoSaveOptions) => {
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastSaveRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const triggerSave = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await onSave();
|
||||||
|
lastSaveRef.current = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto-save failed:', error);
|
||||||
|
}
|
||||||
|
}, debounceMs);
|
||||||
|
}, [onSave, debounceMs, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { triggerSave, lastSaveTime: lastSaveRef.current };
|
||||||
|
};
|
||||||
|
|
||||||
71
frontend/editor/hooks/useNotifications.test.ts
Normal file
71
frontend/editor/hooks/useNotifications.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { useNotifications } from './useNotifications';
|
||||||
|
|
||||||
|
describe('useNotifications hook', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show success notification', () => {
|
||||||
|
const { result } = renderHook(() => useNotifications());
|
||||||
|
|
||||||
|
result.current.success('Saved successfully');
|
||||||
|
|
||||||
|
expect(result.current.notifications).toHaveLength(1);
|
||||||
|
expect(result.current.notifications[0].type).toBe('success');
|
||||||
|
expect(result.current.notifications[0].message).toBe('Saved successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error notification', () => {
|
||||||
|
const { result } = renderHook(() => useNotifications());
|
||||||
|
|
||||||
|
result.current.error('Save failed');
|
||||||
|
|
||||||
|
expect(result.current.notifications).toHaveLength(1);
|
||||||
|
expect(result.current.notifications[0].type).toBe('error');
|
||||||
|
expect(result.current.notifications[0].message).toBe('Save failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-remove notification after duration', async () => {
|
||||||
|
const { result } = renderHook(() => useNotifications());
|
||||||
|
|
||||||
|
result.current.success('Test', 1000);
|
||||||
|
|
||||||
|
expect(result.current.notifications).toHaveLength(1);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.notifications).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove notification manually', () => {
|
||||||
|
const { result } = renderHook(() => useNotifications());
|
||||||
|
|
||||||
|
const id = result.current.success('Test');
|
||||||
|
|
||||||
|
expect(result.current.notifications).toHaveLength(1);
|
||||||
|
|
||||||
|
result.current.removeNotification(id);
|
||||||
|
|
||||||
|
expect(result.current.notifications).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show multiple notifications', () => {
|
||||||
|
const { result } = renderHook(() => useNotifications());
|
||||||
|
|
||||||
|
result.current.success('Success 1');
|
||||||
|
result.current.error('Error 1');
|
||||||
|
result.current.info('Info 1');
|
||||||
|
|
||||||
|
expect(result.current.notifications).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
65
frontend/editor/hooks/useNotifications.ts
Normal file
65
frontend/editor/hooks/useNotifications.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning';
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
const showNotification = useCallback(
|
||||||
|
(type: Notification['type'], message: string, duration = 3000) => {
|
||||||
|
const id = `notif-${Date.now()}-${Math.random()}`;
|
||||||
|
const notification: Notification = { id, type, message, duration };
|
||||||
|
|
||||||
|
setNotifications((prev) => [...prev, notification]);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeNotification = useCallback((id: string) => {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const success = useCallback(
|
||||||
|
(message: string, duration?: number) => showNotification('success', message, duration),
|
||||||
|
[showNotification]
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = useCallback(
|
||||||
|
(message: string, duration?: number) => showNotification('error', message, duration),
|
||||||
|
[showNotification]
|
||||||
|
);
|
||||||
|
|
||||||
|
const info = useCallback(
|
||||||
|
(message: string, duration?: number) => showNotification('info', message, duration),
|
||||||
|
[showNotification]
|
||||||
|
);
|
||||||
|
|
||||||
|
const warning = useCallback(
|
||||||
|
(message: string, duration?: number) => showNotification('warning', message, duration),
|
||||||
|
[showNotification]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
showNotification,
|
||||||
|
removeNotification,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
info,
|
||||||
|
warning,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,28 +1,135 @@
|
||||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { usePageStorage } from './usePageStorage';
|
||||||
|
|
||||||
describe('usePageStorage hook', () => {
|
describe('usePageStorage hook', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
global.fetch = jest.fn();
|
global.fetch = jest.fn();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save page config', async () => {
|
it('should save page config successfully', async () => {
|
||||||
// TODO: Implement test
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
expect(true).toBe(true);
|
ok: true,
|
||||||
|
json: async () => ({ id: '1', page_config: { components: [] } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load page config', async () => {
|
const { result } = renderHook(() => usePageStorage());
|
||||||
// TODO: Implement test
|
|
||||||
expect(true).toBe(true);
|
await result.current.savePage('1', { components: [] });
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/pages/1', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ page_config: { components: [] } }),
|
||||||
|
});
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load page config successfully', async () => {
|
||||||
|
const mockConfig = { components: [{ type: 'Button', properties: {} }] };
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: '1', page_config: mockConfig }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePageStorage());
|
||||||
|
|
||||||
|
const config = await result.current.loadPage('1');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/pages/1');
|
||||||
|
expect(config).toEqual(mockConfig);
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when page not found', async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePageStorage());
|
||||||
|
|
||||||
|
const config = await result.current.loadPage('999');
|
||||||
|
|
||||||
|
expect(config).toBeNull();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle save errors', async () => {
|
it('should handle save errors', async () => {
|
||||||
// TODO: Implement test
|
(global.fetch as jest.Mock).mockRejectedValueOnce(
|
||||||
expect(true).toBe(true);
|
new Error('Network error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePageStorage());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
result.current.savePage('1', { components: [] })
|
||||||
|
).rejects.toThrow('Network error');
|
||||||
|
expect(result.current.error).toBe('Network error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle load errors', async () => {
|
it('should handle load errors', async () => {
|
||||||
// TODO: Implement test
|
(global.fetch as jest.Mock).mockRejectedValueOnce(
|
||||||
expect(true).toBe(true);
|
new Error('Network error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePageStorage());
|
||||||
|
|
||||||
|
const config = await result.current.loadPage('1');
|
||||||
|
|
||||||
|
expect(config).toBeNull();
|
||||||
|
expect(result.current.error).toBe('Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set loading state during save', async () => {
|
||||||
|
let resolveFetch: (value: any) => void;
|
||||||
|
const fetchPromise = new Promise((resolve) => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
});
|
||||||
|
(global.fetch as jest.Mock).mockReturnValueOnce(fetchPromise);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePageStorage());
|
||||||
|
|
||||||
|
const savePromise = result.current.savePage('1', { components: [] });
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
|
||||||
|
resolveFetch!({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await savePromise;
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set loading state during load', async () => {
|
||||||
|
let resolveFetch: (value: any) => void;
|
||||||
|
const fetchPromise = new Promise((resolve) => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
});
|
||||||
|
(global.fetch as jest.Mock).mockReturnValueOnce(fetchPromise);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePageStorage());
|
||||||
|
|
||||||
|
const loadPromise = result.current.loadPage('1');
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
|
||||||
|
resolveFetch!({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ page_config: {} }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadPromise;
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
61
frontend/editor/hooks/useUnsavedChanges.test.ts
Normal file
61
frontend/editor/hooks/useUnsavedChanges.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useUnsavedChanges } from './useUnsavedChanges';
|
||||||
|
|
||||||
|
describe('useUnsavedChanges hook', () => {
|
||||||
|
let beforeUnloadEvent: BeforeUnloadEvent;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
beforeUnloadEvent = new Event('beforeunload') as BeforeUnloadEvent;
|
||||||
|
Object.defineProperty(beforeUnloadEvent, 'preventDefault', {
|
||||||
|
value: jest.fn(),
|
||||||
|
});
|
||||||
|
Object.defineProperty(beforeUnloadEvent, 'returnValue', {
|
||||||
|
writable: true,
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn when there are unsaved changes', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useUnsavedChanges({ hasChanges: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||||
|
|
||||||
|
window.dispatchEvent(beforeUnloadEvent);
|
||||||
|
|
||||||
|
expect(beforeUnloadEvent.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(beforeUnloadEvent.returnValue).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not warn when there are no changes', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useUnsavedChanges({ hasChanges: false })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||||
|
|
||||||
|
window.dispatchEvent(beforeUnloadEvent);
|
||||||
|
|
||||||
|
expect(beforeUnloadEvent.preventDefault).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect onBeforeUnload callback', () => {
|
||||||
|
const onBeforeUnload = jest.fn().mockReturnValue(false);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useUnsavedChanges({ hasChanges: true, onBeforeUnload })
|
||||||
|
);
|
||||||
|
|
||||||
|
window.dispatchEvent(beforeUnloadEvent);
|
||||||
|
|
||||||
|
expect(onBeforeUnload).toHaveBeenCalled();
|
||||||
|
expect(beforeUnloadEvent.preventDefault).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
39
frontend/editor/hooks/useUnsavedChanges.ts
Normal file
39
frontend/editor/hooks/useUnsavedChanges.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface UseUnsavedChangesOptions {
|
||||||
|
hasChanges: boolean;
|
||||||
|
onBeforeUnload?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUnsavedChanges = ({
|
||||||
|
hasChanges,
|
||||||
|
onBeforeUnload,
|
||||||
|
}: UseUnsavedChangesOptions) => {
|
||||||
|
const hasChangesRef = useRef(hasChanges);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasChangesRef.current = hasChanges;
|
||||||
|
}, [hasChanges]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (hasChangesRef.current) {
|
||||||
|
if (onBeforeUnload) {
|
||||||
|
const shouldWarn = onBeforeUnload();
|
||||||
|
if (!shouldWarn) return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [onBeforeUnload]);
|
||||||
|
|
||||||
|
return { hasUnsavedChanges: hasChanges };
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -48,5 +48,76 @@ describe('ComponentRenderer', () => {
|
||||||
const button = screen.getByText('Test');
|
const button = screen.getByText('Test');
|
||||||
expect(button).toHaveClass('btn', 'btn-primary');
|
expect(button).toHaveClass('btn', 'btn-primary');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle unknown component types gracefully', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'UnknownComponent',
|
||||||
|
properties: {},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ComponentRenderer config={config} />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Unknown component/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deeply nested components', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'Container',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'Section',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'Row',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'Column',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'Button',
|
||||||
|
properties: { text: 'Nested Button' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ComponentRenderer config={config} />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Nested Button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle components without properties', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'Spacer',
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ComponentRenderer config={config} />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('spacer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle components with empty children array', () => {
|
||||||
|
const config = {
|
||||||
|
type: 'Container',
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ComponentRenderer config={config} />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('container')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { ErrorInfo } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Heading,
|
Heading,
|
||||||
|
|
@ -23,6 +23,44 @@ interface ComponentRendererProps {
|
||||||
config: ComponentConfig;
|
config: ComponentConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComponentErrorBoundary extends React.Component<
|
||||||
|
{ children: React.ReactNode },
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: { children: React.ReactNode }) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Component render error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="component-error" data-testid="component-error">
|
||||||
|
<p>Error rendering component</p>
|
||||||
|
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||||
|
<pre>{this.state.error.message}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const componentMap: Record<string, React.ComponentType<any>> = {
|
const componentMap: Record<string, React.ComponentType<any>> = {
|
||||||
Button,
|
Button,
|
||||||
Heading,
|
Heading,
|
||||||
|
|
@ -39,17 +77,35 @@ const componentMap: Record<string, React.ComponentType<any>> = {
|
||||||
export const ComponentRenderer: React.FC<ComponentRendererProps> = ({
|
export const ComponentRenderer: React.FC<ComponentRendererProps> = ({
|
||||||
config,
|
config,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (!config || !config.type) {
|
||||||
|
return (
|
||||||
|
<div className="component-error" data-testid="component-error">
|
||||||
|
Invalid component config
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Component = componentMap[config.type];
|
const Component = componentMap[config.type];
|
||||||
|
|
||||||
if (!Component) {
|
if (!Component) {
|
||||||
return <div>Unknown component: {config.type}</div>;
|
return (
|
||||||
|
<div className="component-error" data-testid="component-error">
|
||||||
|
Unknown component: {config.type}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = config.properties || {};
|
const props = config.properties || {};
|
||||||
const children = config.children?.map((child, index) => (
|
const children = config.children?.map((child, index) => (
|
||||||
<ComponentRenderer key={child.id || index} config={child} />
|
<ComponentErrorBoundary key={child.id || `child-${index}`}>
|
||||||
|
<ComponentRenderer config={child} />
|
||||||
|
</ComponentErrorBoundary>
|
||||||
));
|
));
|
||||||
|
|
||||||
return <Component {...props}>{children}</Component>;
|
return (
|
||||||
|
<ComponentErrorBoundary>
|
||||||
|
<Component {...props}>{children}</Component>
|
||||||
|
</ComponentErrorBoundary>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,57 @@ describe('LayoutEngine', () => {
|
||||||
render(<LayoutEngine pageConfig={pageConfig} />);
|
render(<LayoutEngine pageConfig={pageConfig} />);
|
||||||
expect(screen.getByTestId('layout-engine')).toBeInTheDocument();
|
expect(screen.getByTestId('layout-engine')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle missing components array', () => {
|
||||||
|
const pageConfig = {};
|
||||||
|
render(<LayoutEngine pageConfig={pageConfig} />);
|
||||||
|
expect(screen.getByTestId('layout-engine')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render multiple root components', () => {
|
||||||
|
const pageConfig = {
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'Heading',
|
||||||
|
properties: { text: 'Title', level: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Paragraph',
|
||||||
|
properties: { text: 'Description' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
render(<LayoutEngine pageConfig={pageConfig} />);
|
||||||
|
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex nested structure', () => {
|
||||||
|
const pageConfig = {
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'Section',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'Container',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'Heading',
|
||||||
|
properties: { text: 'Section Title', level: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Button',
|
||||||
|
properties: { text: 'Action' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
render(<LayoutEngine pageConfig={pageConfig} />);
|
||||||
|
expect(screen.getByText('Section Title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Action')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface PageConfig {
|
||||||
properties?: Record<string, any>;
|
properties?: Record<string, any>;
|
||||||
children?: any[];
|
children?: any[];
|
||||||
}>;
|
}>;
|
||||||
|
styles?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutEngineProps {
|
interface LayoutEngineProps {
|
||||||
|
|
@ -15,13 +16,30 @@ interface LayoutEngineProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayoutEngine: React.FC<LayoutEngineProps> = ({ pageConfig }) => {
|
export const LayoutEngine: React.FC<LayoutEngineProps> = ({ pageConfig }) => {
|
||||||
const components = pageConfig.components || [];
|
if (!pageConfig) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="layout-engine" className="layout-engine">
|
<div data-testid="layout-engine" className="layout-engine">
|
||||||
{components.map((comp, index) => (
|
<p>No page configuration provided</p>
|
||||||
<ComponentRenderer key={comp.id || index} config={comp} />
|
</div>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = pageConfig.components || [];
|
||||||
|
const styles = pageConfig.styles || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="layout-engine"
|
||||||
|
className="layout-engine"
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
{components.length === 0 ? (
|
||||||
|
<div className="empty-page">No components to display</div>
|
||||||
|
) : (
|
||||||
|
components.map((comp, index) => (
|
||||||
|
<ComponentRenderer key={comp.id || `comp-${index}`} config={comp} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
25
frontend/renderer/main.tsx
Normal file
25
frontend/renderer/main.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { LayoutEngine } from './LayoutEngine';
|
||||||
|
|
||||||
|
interface PageConfig {
|
||||||
|
components?: Array<{
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
children?: any[];
|
||||||
|
}>;
|
||||||
|
styles?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get page config from window
|
||||||
|
const pageConfig: PageConfig = (window as any).__PAGE_CONFIG__ || {
|
||||||
|
components: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (root) {
|
||||||
|
const reactRoot = createRoot(root);
|
||||||
|
reactRoot.render(<LayoutEngine pageConfig={pageConfig} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export default {
|
export default {
|
||||||
preset: 'ts-jest/presets/default-esm',
|
preset: 'ts-jest/presets/default-esm',
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||||
setupFilesAfterEnv: [
|
setupFilesAfterEnv: [
|
||||||
'<rootDir>/jest.setup.js',
|
'<rootDir>/jest.setup.js',
|
||||||
|
|
|
||||||
49
server.js
49
server.js
|
|
@ -85,10 +85,53 @@ async function serveEditor(req, res) {
|
||||||
|
|
||||||
async function previewPage(req, res) {
|
async function previewPage(req, res) {
|
||||||
try {
|
try {
|
||||||
// TODO: Implement in WB-007
|
const pageId = req.params.id;
|
||||||
res.status(501).json({ error: 'Not implemented yet' });
|
|
||||||
|
if (!bosa) {
|
||||||
|
bosa.log?.error('PreviewPage: BOSA SDK not initialized');
|
||||||
|
return res.status(500).json({ error: 'Server not initialized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load page config using BOSA SDK
|
||||||
|
const page = await bosa.db
|
||||||
|
.query('wb_pages')
|
||||||
|
.where('id', '=', Number(pageId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
bosa.log?.warn(`PreviewPage: Page not found | ID: ${pageId}`);
|
||||||
|
return res.status(404).json({ error: 'Page not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageConfig = JSON.parse(page.page_config);
|
||||||
|
|
||||||
|
// Render preview HTML with LayoutEngine
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Preview - Page ${pageId}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 20px; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
|
.preview-container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="preview-container" id="root"></div>
|
||||||
|
<script>
|
||||||
|
window.__PAGE_CONFIG__ = ${JSON.stringify(pageConfig)};
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/bp_wb/renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
res.send(html);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
bosa.log.error(`PreviewPage failed | Page ID: ${req.params.id} | Error: ${error.message}`);
|
bosa.log?.error(`PreviewPage failed | Page ID: ${req.params.id} | Error: ${error.message}`);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
version.txt
23
version.txt
|
|
@ -1,7 +1,28 @@
|
||||||
BP_WB Version 0.0.0.003
|
BP_WB Version 0.0.0.004
|
||||||
Date: December 21, 2025
|
Date: December 21, 2025
|
||||||
|
|
||||||
=== Latest Changes ===
|
=== Latest Changes ===
|
||||||
|
- [FEATURE] WB-007: Basic Renderer - Complete renderer implementation
|
||||||
|
- Enhanced ComponentRenderer with error boundaries for graceful error handling
|
||||||
|
- Added support for deeply nested components with proper error isolation
|
||||||
|
- Enhanced LayoutEngine to handle empty configs, missing components, and complex structures
|
||||||
|
- Implemented preview page route handler in server.js using BOSA SDK
|
||||||
|
- Created renderer entry point (frontend/renderer/main.tsx) for preview functionality
|
||||||
|
- Added comprehensive tests for renderer components (7 tests passing)
|
||||||
|
- Components now handle unknown types, missing properties, and empty children gracefully
|
||||||
|
- [FEATURE] WB-008: Save/Load Functionality - Complete save/load system with auto-save
|
||||||
|
- Implemented useAutoSave hook with debounced auto-save (2 second delay)
|
||||||
|
- Created useUnsavedChanges hook with beforeunload warning
|
||||||
|
- Implemented useNotifications hook for success/error/info/warning messages
|
||||||
|
- Created NotificationToast component for displaying notifications
|
||||||
|
- Enhanced Editor component with auto-save, unsaved changes tracking, and notifications
|
||||||
|
- Updated Toolbar to show unsaved changes indicator (*) and status
|
||||||
|
- Added loading states during save/load operations
|
||||||
|
- Implemented proper error handling and user feedback
|
||||||
|
- Page loads automatically on editor open
|
||||||
|
- Auto-save triggers on component changes (debounced)
|
||||||
|
- Unsaved changes warning prevents accidental navigation
|
||||||
|
- Comprehensive tests for all hooks (usePageStorage, useAutoSave, useUnsavedChanges, useNotifications)
|
||||||
- [FEATURE] Added deployment script and sidebar integration
|
- [FEATURE] Added deployment script and sidebar integration
|
||||||
- Created deploy_wb.bat script in D:\dev\projects\BOSA\apps\ for easy deployment
|
- Created deploy_wb.bat script in D:\dev\projects\BOSA\apps\ for easy deployment
|
||||||
- Added sidebar configuration to manifest.yaml for Super Admin link
|
- Added sidebar configuration to manifest.yaml for Super Admin link
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,17 @@ export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: path.resolve(__dirname, 'frontend/index.html'),
|
||||||
|
renderer: path.resolve(__dirname, 'frontend/renderer/main.tsx'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: (chunkInfo) => {
|
||||||
|
return chunkInfo.name === 'renderer' ? 'renderer.js' : '[name].js';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue