From 120b7285dc818990a0e629484cc93775d97f117e Mon Sep 17 00:00:00 2001 From: mmabdalla <101379618+mmabdalla@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:50:17 +0200 Subject: [PATCH] Complete WB-007 and WB-008: Basic Renderer and Save/Load with Auto-save --- frontend/components/base/Container.tsx | 6 +- frontend/components/base/Spacer.tsx | 7 +- frontend/editor/components/Editor.tsx | 121 ++++++++++++++--- .../components/NotificationToast.test.tsx | 53 ++++++++ .../editor/components/NotificationToast.tsx | 31 +++++ frontend/editor/components/Toolbar.tsx | 10 +- frontend/editor/components/testUtils.tsx | 5 +- frontend/editor/hooks/useAutoSave.test.ts | 62 +++++++++ frontend/editor/hooks/useAutoSave.ts | 44 ++++++ .../editor/hooks/useNotifications.test.ts | 71 ++++++++++ frontend/editor/hooks/useNotifications.ts | 65 +++++++++ frontend/editor/hooks/usePageStorage.test.ts | 127 ++++++++++++++++-- .../editor/hooks/useUnsavedChanges.test.ts | 61 +++++++++ frontend/editor/hooks/useUnsavedChanges.ts | 39 ++++++ frontend/renderer/ComponentRenderer.test.tsx | 71 ++++++++++ frontend/renderer/ComponentRenderer.tsx | 64 ++++++++- frontend/renderer/LayoutEngine.test.tsx | 52 +++++++ frontend/renderer/LayoutEngine.tsx | 26 +++- frontend/renderer/main.tsx | 25 ++++ jest.config.js | 1 + server.js | 49 ++++++- version.txt | 23 +++- vite.config.js | 11 ++ 23 files changed, 977 insertions(+), 47 deletions(-) create mode 100644 frontend/editor/components/NotificationToast.test.tsx create mode 100644 frontend/editor/components/NotificationToast.tsx create mode 100644 frontend/editor/hooks/useAutoSave.test.ts create mode 100644 frontend/editor/hooks/useAutoSave.ts create mode 100644 frontend/editor/hooks/useNotifications.test.ts create mode 100644 frontend/editor/hooks/useNotifications.ts create mode 100644 frontend/editor/hooks/useUnsavedChanges.test.ts create mode 100644 frontend/editor/hooks/useUnsavedChanges.ts create mode 100644 frontend/renderer/main.tsx diff --git a/frontend/components/base/Container.tsx b/frontend/components/base/Container.tsx index be63b9b..c67cc41 100644 --- a/frontend/components/base/Container.tsx +++ b/frontend/components/base/Container.tsx @@ -9,6 +9,10 @@ export const Container: React.FC = ({ children, className = '', }) => { - return
{children}
; + return ( +
+ {children} +
+ ); }; diff --git a/frontend/components/base/Spacer.tsx b/frontend/components/base/Spacer.tsx index cf14e8b..454c788 100644 --- a/frontend/components/base/Spacer.tsx +++ b/frontend/components/base/Spacer.tsx @@ -6,6 +6,11 @@ export interface SpacerProps { } export const Spacer: React.FC = ({ height = 20, width = 0 }) => { - return
; + return ( +
+ ); }; diff --git a/frontend/editor/components/Editor.tsx b/frontend/editor/components/Editor.tsx index cfafeb6..10c061a 100644 --- a/frontend/editor/components/Editor.tsx +++ b/frontend/editor/components/Editor.tsx @@ -1,12 +1,16 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { Toolbar } from './Toolbar'; import { ComponentPalette } from './ComponentPalette'; import { PropertyPanel } from './PropertyPanel'; import { Canvas } from './Canvas'; +import { NotificationToast } from './NotificationToast'; import { DragItem } from '../hooks/useDragDrop'; import { usePageStorage } from '../hooks/usePageStorage'; +import { useAutoSave } from '../hooks/useAutoSave'; +import { useUnsavedChanges } from '../hooks/useUnsavedChanges'; +import { useNotifications } from '../hooks/useNotifications'; import './Editor.css'; interface EditorProps { @@ -14,37 +18,106 @@ interface EditorProps { } export const Editor: React.FC = ({ pageId }) => { - const [isLoading, setIsLoading] = useState(false); const [selectedComponent, setSelectedComponent] = useState(null); const [canvasComponents, setCanvasComponents] = useState([]); + const [initialComponents, setInitialComponents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const hasLoadedRef = useRef(false); 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 { + setIsLoading(true); await savePage(pageId, { components: canvasComponents }); - // Show success notification - } catch (error) { - // Show error notification + setInitialComponents(canvasComponents); + if (showNotification) { + 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 = () => { - // TODO: Implement preview logic + if (!pageId) { + notifications.warning('No page ID provided'); + return; + } + window.open(`/preview/${pageId}`, '_blank'); }; 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 = '/'; }; @@ -65,10 +138,11 @@ export const Editor: React.FC = ({ pageId }) => {
handleSave(true)} onPreview={handlePreview} onExit={handleExit} isLoading={isLoading || storageLoading} + hasUnsavedChanges={hasUnsavedChanges} />
@@ -84,6 +158,15 @@ export const Editor: React.FC = ({ pageId }) => {
+
+ {notifications.notifications.map((notif) => ( + notifications.removeNotification(notif.id)} + /> + ))} +
); diff --git a/frontend/editor/components/NotificationToast.test.tsx b/frontend/editor/components/NotificationToast.test.tsx new file mode 100644 index 0000000..bbaea4c --- /dev/null +++ b/frontend/editor/components/NotificationToast.test.tsx @@ -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(); + + 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(); + + 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(); + + const closeButton = screen.getByTestId('notification-close'); + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/frontend/editor/components/NotificationToast.tsx b/frontend/editor/components/NotificationToast.tsx new file mode 100644 index 0000000..fc8c8b3 --- /dev/null +++ b/frontend/editor/components/NotificationToast.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Notification } from '../hooks/useNotifications'; + +interface NotificationToastProps { + notification: Notification; + onClose: () => void; +} + +export const NotificationToast: React.FC = ({ + notification, + onClose, +}) => { + return ( +
+ {notification.message} + +
+ ); +}; + diff --git a/frontend/editor/components/Toolbar.tsx b/frontend/editor/components/Toolbar.tsx index d31b950..9f730a7 100644 --- a/frontend/editor/components/Toolbar.tsx +++ b/frontend/editor/components/Toolbar.tsx @@ -5,6 +5,7 @@ interface ToolbarProps { onPreview?: () => void; onExit?: () => void; isLoading?: boolean; + hasUnsavedChanges?: boolean; } export const Toolbar: React.FC = ({ @@ -12,6 +13,7 @@ export const Toolbar: React.FC = ({ onPreview, onExit, isLoading = false, + hasUnsavedChanges = false, }) => { return (
@@ -20,8 +22,9 @@ export const Toolbar: React.FC = ({ onClick={onSave} disabled={isLoading} aria-label="Save page" + className={hasUnsavedChanges ? 'has-changes' : ''} > - Save + Save {hasUnsavedChanges && '*'}
); }; diff --git a/frontend/editor/components/testUtils.tsx b/frontend/editor/components/testUtils.tsx index f338c3c..f8fbac0 100644 --- a/frontend/editor/components/testUtils.tsx +++ b/frontend/editor/components/testUtils.tsx @@ -1,10 +1,9 @@ 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 }> = ({ children, }) => { - return {children}; + return <>{children}; }; diff --git a/frontend/editor/hooks/useAutoSave.test.ts b/frontend/editor/hooks/useAutoSave.test.ts new file mode 100644 index 0000000..a1950d2 --- /dev/null +++ b/frontend/editor/hooks/useAutoSave.test.ts @@ -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); + }); + }); +}); + diff --git a/frontend/editor/hooks/useAutoSave.ts b/frontend/editor/hooks/useAutoSave.ts new file mode 100644 index 0000000..593d72f --- /dev/null +++ b/frontend/editor/hooks/useAutoSave.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef, useCallback } from 'react'; + +interface UseAutoSaveOptions { + onSave: () => Promise; + debounceMs?: number; + enabled?: boolean; +} + +export const useAutoSave = ({ + onSave, + debounceMs = 2000, + enabled = true, +}: UseAutoSaveOptions) => { + const timeoutRef = useRef(null); + const lastSaveRef = useRef(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 }; +}; + diff --git a/frontend/editor/hooks/useNotifications.test.ts b/frontend/editor/hooks/useNotifications.test.ts new file mode 100644 index 0000000..7c97683 --- /dev/null +++ b/frontend/editor/hooks/useNotifications.test.ts @@ -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); + }); +}); + diff --git a/frontend/editor/hooks/useNotifications.ts b/frontend/editor/hooks/useNotifications.ts new file mode 100644 index 0000000..53f332f --- /dev/null +++ b/frontend/editor/hooks/useNotifications.ts @@ -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([]); + + 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, + }; +}; + diff --git a/frontend/editor/hooks/usePageStorage.test.ts b/frontend/editor/hooks/usePageStorage.test.ts index adbd299..439e2ee 100644 --- a/frontend/editor/hooks/usePageStorage.test.ts +++ b/frontend/editor/hooks/usePageStorage.test.ts @@ -1,28 +1,135 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { renderHook, waitFor } from '@testing-library/react'; +import { usePageStorage } from './usePageStorage'; describe('usePageStorage hook', () => { beforeEach(() => { global.fetch = jest.fn(); + jest.clearAllMocks(); }); - it('should save page config', async () => { - // TODO: Implement test - expect(true).toBe(true); + it('should save page config successfully', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: '1', page_config: { components: [] } }), + }); + + const { result } = renderHook(() => usePageStorage()); + + 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', async () => { - // TODO: Implement test - expect(true).toBe(true); + 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 () => { - // TODO: Implement test - expect(true).toBe(true); + (global.fetch as jest.Mock).mockRejectedValueOnce( + 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 () => { - // TODO: Implement test - expect(true).toBe(true); + (global.fetch as jest.Mock).mockRejectedValueOnce( + 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); + }); }); }); diff --git a/frontend/editor/hooks/useUnsavedChanges.test.ts b/frontend/editor/hooks/useUnsavedChanges.test.ts new file mode 100644 index 0000000..043adf0 --- /dev/null +++ b/frontend/editor/hooks/useUnsavedChanges.test.ts @@ -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(); + }); +}); + diff --git a/frontend/editor/hooks/useUnsavedChanges.ts b/frontend/editor/hooks/useUnsavedChanges.ts new file mode 100644 index 0000000..cacca6a --- /dev/null +++ b/frontend/editor/hooks/useUnsavedChanges.ts @@ -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 }; +}; + diff --git a/frontend/renderer/ComponentRenderer.test.tsx b/frontend/renderer/ComponentRenderer.test.tsx index 97ab7f2..bad2839 100644 --- a/frontend/renderer/ComponentRenderer.test.tsx +++ b/frontend/renderer/ComponentRenderer.test.tsx @@ -48,5 +48,76 @@ describe('ComponentRenderer', () => { const button = screen.getByText('Test'); expect(button).toHaveClass('btn', 'btn-primary'); }); + + it('should handle unknown component types gracefully', () => { + const config = { + type: 'UnknownComponent', + properties: {}, + }; + render( + + + + ); + 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( + + + + ); + expect(screen.getByText('Nested Button')).toBeInTheDocument(); + }); + + it('should handle components without properties', () => { + const config = { + type: 'Spacer', + }; + render( + + + + ); + expect(screen.getByTestId('spacer')).toBeInTheDocument(); + }); + + it('should handle components with empty children array', () => { + const config = { + type: 'Container', + children: [], + }; + render( + + + + ); + expect(screen.getByTestId('container')).toBeInTheDocument(); + }); }); diff --git a/frontend/renderer/ComponentRenderer.tsx b/frontend/renderer/ComponentRenderer.tsx index 5be810e..94d3c0f 100644 --- a/frontend/renderer/ComponentRenderer.tsx +++ b/frontend/renderer/ComponentRenderer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ErrorInfo } from 'react'; import { Button, Heading, @@ -23,6 +23,44 @@ interface ComponentRendererProps { 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 ( +
+

Error rendering component

+ {process.env.NODE_ENV === 'development' && this.state.error && ( +
{this.state.error.message}
+ )} +
+ ); + } + + return this.props.children; + } +} + const componentMap: Record> = { Button, Heading, @@ -39,17 +77,35 @@ const componentMap: Record> = { export const ComponentRenderer: React.FC = ({ config, }) => { + if (!config || !config.type) { + return ( +
+ Invalid component config +
+ ); + } + const Component = componentMap[config.type]; if (!Component) { - return
Unknown component: {config.type}
; + return ( +
+ Unknown component: {config.type} +
+ ); } const props = config.properties || {}; const children = config.children?.map((child, index) => ( - + + + )); - return {children}; + return ( + + {children} + + ); }; diff --git a/frontend/renderer/LayoutEngine.test.tsx b/frontend/renderer/LayoutEngine.test.tsx index d0296ac..6790679 100644 --- a/frontend/renderer/LayoutEngine.test.tsx +++ b/frontend/renderer/LayoutEngine.test.tsx @@ -21,5 +21,57 @@ describe('LayoutEngine', () => { render(); expect(screen.getByTestId('layout-engine')).toBeInTheDocument(); }); + + it('should handle missing components array', () => { + const pageConfig = {}; + render(); + 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(); + 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(); + expect(screen.getByText('Section Title')).toBeInTheDocument(); + expect(screen.getByText('Action')).toBeInTheDocument(); + }); }); diff --git a/frontend/renderer/LayoutEngine.tsx b/frontend/renderer/LayoutEngine.tsx index ca391dc..4343d12 100644 --- a/frontend/renderer/LayoutEngine.tsx +++ b/frontend/renderer/LayoutEngine.tsx @@ -8,6 +8,7 @@ interface PageConfig { properties?: Record; children?: any[]; }>; + styles?: Record; } interface LayoutEngineProps { @@ -15,13 +16,30 @@ interface LayoutEngineProps { } export const LayoutEngine: React.FC = ({ pageConfig }) => { + if (!pageConfig) { + return ( +
+

No page configuration provided

+
+ ); + } + const components = pageConfig.components || []; + const styles = pageConfig.styles || {}; return ( -
- {components.map((comp, index) => ( - - ))} +
+ {components.length === 0 ? ( +
No components to display
+ ) : ( + components.map((comp, index) => ( + + )) + )}
); }; diff --git a/frontend/renderer/main.tsx b/frontend/renderer/main.tsx new file mode 100644 index 0000000..7c17cdc --- /dev/null +++ b/frontend/renderer/main.tsx @@ -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; + children?: any[]; + }>; + styles?: Record; +} + +// 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(); +} + diff --git a/jest.config.js b/jest.config.js index 702d2c3..f80a4cc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,6 @@ export default { preset: 'ts-jest/presets/default-esm', + testEnvironment: 'jsdom', extensionsToTreatAsEsm: ['.ts', '.tsx'], setupFilesAfterEnv: [ '/jest.setup.js', diff --git a/server.js b/server.js index 99b0009..dd1d5a6 100644 --- a/server.js +++ b/server.js @@ -85,10 +85,53 @@ async function serveEditor(req, res) { async function previewPage(req, res) { try { - // TODO: Implement in WB-007 - res.status(501).json({ error: 'Not implemented yet' }); + const pageId = req.params.id; + + 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 = ` + + + + + + Preview - Page ${pageId} + + + +
+ + + + + `; + + res.setHeader('Content-Type', 'text/html'); + res.send(html); } 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' }); } } diff --git a/version.txt b/version.txt index 2f92291..c1a743b 100644 --- a/version.txt +++ b/version.txt @@ -1,7 +1,28 @@ -BP_WB Version 0.0.0.003 +BP_WB Version 0.0.0.004 Date: December 21, 2025 === 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 - Created deploy_wb.bat script in D:\dev\projects\BOSA\apps\ for easy deployment - Added sidebar configuration to manifest.yaml for Super Admin link diff --git a/vite.config.js b/vite.config.js index e51ae6f..eae3c78 100644 --- a/vite.config.js +++ b/vite.config.js @@ -21,6 +21,17 @@ export default defineConfig({ build: { outDir: 'dist', 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: { port: 5173,