First push

This commit is contained in:
Sam Redfern committed 2025-04-28 10:06:04 +10:00
1 parent db25d6dbda
commit 7299578599
48 files changed
+17513 -1

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
import React, { useRef, useCallback } from 'react';
import { Box, Center } from '@chakra-ui/react';
import { Stage, Layer, Rect } from 'react-konva';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/store';
import { selectComponent, clearSelection } from '@/store/slices/selectionSlice';
import ComponentRenderer from './ComponentRenderer';
import useCanvasSize from '@/hooks/useCanvasSize';
import useCanvasZoom from '@/hooks/useCanvasZoom';
const Canvas: React.FC = () => {
const dispatch = useDispatch();
const stageRef = useRef<any>(null);
const { mockup } = useSelector((state: RootState) => state);
const { components, canvas } = mockup;
// Custom hooks for canvas operations
const { containerSize, canvasContainerRef } = useCanvasSize();
const { scale, position, handleWheel, handleDragMove } = useCanvasZoom();
// Handle background click (deselect all)
const handleStageClick = useCallback((e: any) => {
// If clicked on the stage background
if (e.target === e.currentTarget) {
dispatch(clearSelection());
}
}, [dispatch]);
// Handle component selection
const handleComponentClick = useCallback((id: string, e: any) => {
e.cancelBubble = true; // Stop propagation to parent
dispatch(selectComponent(id));
}, [dispatch]);
return (
<Center
flex="1"
bg="gray.100"
overflow="hidden"
ref={canvasContainerRef}
position="relative"
>
<Box
position="absolute"
top="0"
left="0"
bottom="0"
right="0"
className="canvas-grid"
/>
<Stage
ref={stageRef}
width={containerSize.width}
height={containerSize.height}
onClick={handleStageClick}
onWheel={handleWheel}
draggable
onDragMove={handleDragMove}
x={position.x}
y={position.y}
scaleX={scale}
scaleY={scale}
>
<Layer>
{/* Canvas background */}
<Rect
x={0}
y={0}
width={canvas.width}
height={canvas.height}
fill={canvas.background}
shadowColor="rgba(0,0,0,0.2)"
shadowBlur={10}
shadowOffsetX={0}
shadowOffsetY={0}
cornerRadius={4}
/>
{/* Render components */}
{components.map((component) => (
<ComponentRenderer
key={component.id}
component={component}
onClick={(e) => handleComponentClick(component.id, e)}
/>
))}
</Layer>
</Stage>
</Center>
);
};
export default Canvas;
@@ -0,0 +1,58 @@
import React from 'react';
import { Group } from 'react-konva';
import { useSelector } from 'react-redux';
import { RootState } from '@/store';
import { Component } from '@/types/mockup';
import ChartComponent from './components/ChartComponent';
import FunnelComponent from './components/FunnelComponent';
interface ComponentRendererProps {
component: Component;
onClick?: (e: any) => void;
}
const ComponentRenderer: React.FC<ComponentRendererProps> = ({ component, onClick }) => {
const selection = useSelector((state: RootState) => state.selection);
const isSelected = selection?.selectedComponentId === component.id;
// Common props for all component types
const commonProps = {
id: component.id,
position: component.position,
size: component.size,
style: component.style,
isSelected,
onClick,
};
// Only render components that we know exist
switch (component.type) {
case 'chart':
return (
<ChartComponent
{...commonProps}
data={component.data}
/>
);
case 'funnel':
return (
<FunnelComponent
{...commonProps}
data={component.data}
/>
);
default:
// Draw a placeholder for other component types
return (
<Group
x={component.position.x}
y={component.position.y}
onClick={onClick}
/>
);
}
};
export default ComponentRenderer;
@@ -0,0 +1,116 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import ComponentRenderer from '../ComponentRenderer';
import { ChartData } from '@/types/mockup';
// Mock the components used by ComponentRenderer
vi.mock('../components/ChartComponent', () => ({
default: ({ id, data }: { id: string; data: ChartData }) => (
<div data-testid="chart-component" data-id={id}>
{data.title}
</div>
),
}));
vi.mock('../components/FunnelComponent', () => ({
default: ({ id, data }: { id: string; data: any }) => (
<div data-testid="funnel-component" data-id={id}>
{data.steps ? data.steps.length : 0} steps
</div>
),
}));
// Mock Konva components
vi.mock('react-konva', () => ({
Group: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
<div data-testid="konva-group" onClick={onClick}>
{children}
</div>
),
}));
describe('ComponentRenderer', () => {
let store: any;
beforeEach(() => {
store = configureStore({
reducer: {
selection: (state = { selectedComponentId: 'chart-1' }) => state
}
});
});
test('renders chart component correctly', () => {
const chartComponent = {
id: 'chart-1',
type: 'chart',
position: { x: 10, y: 20 },
size: { width: 400, height: 300 },
data: {
title: 'Test Chart',
labels: ['Jan', 'Feb', 'Mar'],
datasets: [
{ label: 'Dataset 1', data: [10, 20, 30], backgroundColor: '#4caf50' }
]
}
};
render(
<Provider store={store}>
<ComponentRenderer component={chartComponent} onClick={() => {}} />
</Provider>
);
const chartElement = screen.getByTestId('chart-component');
expect(chartElement).toBeTruthy();
expect(chartElement.textContent).toBe('Test Chart');
expect(chartElement.getAttribute('data-id')).toBe('chart-1');
});
test('renders funnel component correctly', () => {
const funnelComponent = {
id: 'funnel-1',
type: 'funnel',
position: { x: 10, y: 20 },
size: { width: 400, height: 300 },
data: {
steps: [
{ label: 'Step 1', value: 1000, dropoff: 0 },
{ label: 'Step 2', value: 600, dropoff: 40 }
]
}
};
render(
<Provider store={store}>
<ComponentRenderer component={funnelComponent} onClick={() => {}} />
</Provider>
);
const funnelElement = screen.getByTestId('funnel-component');
expect(funnelElement).toBeTruthy();
expect(funnelElement.textContent).toBe('2 steps');
expect(funnelElement.getAttribute('data-id')).toBe('funnel-1');
});
test('renders placeholder for unknown component types', () => {
const unknownComponent = {
id: 'unknown-1',
type: 'unknown-type',
position: { x: 10, y: 20 },
size: { width: 400, height: 300 },
data: {}
};
render(
<Provider store={store}>
<ComponentRenderer component={unknownComponent} onClick={() => {}} />
</Provider>
);
expect(screen.getByTestId('konva-group')).toBeTruthy();
});
});
@@ -0,0 +1,169 @@
import React from 'react';
import { Group, Rect, Line, Text } from 'react-konva';
import { ChartComponent as ChartComponentType, ChartData, ChartOptions } from '@/types/mockup';
interface ChartComponentProps {
id: string;
position: ChartComponentType['position'];
size?: ChartComponentType['size'];
style?: ChartComponentType['style'];
data: ChartData;
isSelected?: boolean;
onClick?: (e: any) => void;
}
const ChartComponent: React.FC<ChartComponentProps> = ({
size,
data,
style,
isSelected,
onClick
}) => {
if (!size) return null;
const { title, labels = [], datasets = [] } = data;
const options: ChartOptions = (data as any).options || {};
// Extract colors from style or use defaults
const backgroundColor = style?.backgroundColor || 'white';
const colorList = datasets.map((dataset, index) => {
if (Array.isArray(dataset.backgroundColor)) return dataset.backgroundColor[0];
return dataset.backgroundColor || ['#4caf50', '#2196f3', '#ff9800', '#e91e63'][index % 4];
});
// Currently these options aren't being used but could be in future enhancements
// const {
// showLegend = true,
// showGrid = true,
// showTooltip = true
// } = options;
const padding = 40;
const chartWidth = size.width - padding * 2;
const chartHeight = size.height - padding * 2;
const renderBarChart = () => {
const barWidth = chartWidth / (labels.length * 2);
const allValues = datasets.flatMap(dataset => dataset.data);
const maxValue = Math.max(...allValues, 1); // Prevent division by zero
return (
<Group>
{/* X and Y axis */}
<Line
points={[padding, padding, padding, size.height - padding]}
stroke="black"
strokeWidth={1}
/>
<Line
points={[padding, size.height - padding, size.width - padding, size.height - padding]}
stroke="black"
strokeWidth={1}
/>
{/* Render bars for each data point */}
{datasets.map((dataset, datasetIndex) => (
<Group key={datasetIndex}>
{dataset.data.map((value, index) => {
const barHeight = (value / maxValue) * chartHeight;
const xPos = padding + index * (barWidth * 2) + datasetIndex * barWidth;
const yPos = size.height - padding - barHeight;
const barColor = Array.isArray(dataset.backgroundColor)
? dataset.backgroundColor[index % dataset.backgroundColor.length]
: dataset.backgroundColor || colorList[datasetIndex];
return (
<Group key={`bar-${datasetIndex}-${index}`}>
<Rect
x={xPos}
y={yPos}
width={barWidth}
height={barHeight}
fill={barColor as string}
/>
{/* Display values on bars */}
<Text
text={value.toString()}
x={xPos}
y={yPos - 15}
fontSize={style?.fontSize || 10}
fontFamily={style?.fontFamily || 'Arial'}
fill="black"
width={barWidth}
align="center"
/>
</Group>
);
})}
</Group>
))}
{/* X axis labels */}
{labels.map((label, index) => {
const xPos = padding + index * (barWidth * 2);
return (
<Text
key={`label-${index}`}
text={label}
x={xPos}
y={size.height - padding + 5}
fontSize={style?.fontSize || 10}
fontFamily={style?.fontFamily || 'Arial'}
fill="black"
width={barWidth * 2}
align="center"
/>
);
})}
</Group>
);
};
return (
<Group onClick={onClick}>
{/* Chart background */}
<Rect
width={size.width}
height={size.height}
fill={backgroundColor}
cornerRadius={style?.borderRadius || 0}
shadowColor="rgba(0,0,0,0.1)"
shadowBlur={5}
shadowOffsetY={2}
/>
{/* Chart title */}
{title && (
<Text
text={title}
x={padding}
y={10}
fontSize={style?.fontSize ? style.fontSize + 2 : 14}
fontFamily={style?.fontFamily || 'Arial'}
fontStyle="bold"
fill="black"
width={size.width - padding * 2}
align="center"
/>
)}
{/* Render bar chart (we can add other chart types later) */}
{renderBarChart()}
{/* Add selection indicator if selected */}
{isSelected && (
<Rect
width={size.width}
height={size.height}
stroke="#2196f3"
strokeWidth={2}
/>
)}
</Group>
);
};
export default ChartComponent;
@@ -0,0 +1,105 @@
import React from 'react';
import { Group, Rect, Text } from 'react-konva';
import { FunnelComponent as FunnelComponentType } from '@/types/mockup';
interface FunnelComponentProps {
id: string;
position: FunnelComponentType['position'];
size?: FunnelComponentType['size'];
style?: FunnelComponentType['style'];
data: FunnelComponentType['data'];
isSelected?: boolean;
onClick?: (e: any) => void;
}
const FunnelComponent: React.FC<FunnelComponentProps> = ({
size,
data,
style,
isSelected,
onClick
}) => {
if (!size) return null;
const { steps, colors = {}, options = {} } = data;
const {
primary = '#1a2b47',
secondary = '#e0756b',
tertiary = '#d3d3d3',
success = '#4caf50'
} = colors;
const {
showPercentages = true,
showLabels = true,
orientation = 'vertical'
} = options;
const stepHeight = size.height / (steps.length + 1);
return (
<Group onClick={onClick}>
{/* Funnel background/container */}
<Rect
width={size.width}
height={size.height}
fill={style?.backgroundColor || 'white'}
cornerRadius={style?.borderRadius || 0}
shadowColor="rgba(0,0,0,0.1)"
shadowBlur={5}
shadowOffsetY={2}
/>
{/* Funnel steps */}
{steps.map((step, index) => {
const yPos = stepHeight * (index + 0.5);
const stepWidth = size.width * (1 - (index * 0.15));
const xPos = (size.width - stepWidth) / 2;
const fillColor = step.conversion ? success : primary;
return (
<Group key={index} y={yPos}>
{/* Step bar */}
<Rect
x={xPos}
width={stepWidth}
height={stepHeight * 0.6}
fill={fillColor}
cornerRadius={4}
/>
{/* Step label */}
{showLabels && (
<Text
text={step.label}
x={xPos + 10}
y={10}
fontSize={style?.fontSize || 12}
fontFamily={style?.fontFamily || 'Arial'}
fill="white"
width={stepWidth - 20}
align="left"
/>
)}
{/* Dropoff percentage */}
{showPercentages && step.dropoff && (
<Text
text={`${step.dropoff}%`}
x={xPos + stepWidth - 50}
y={10}
fontSize={style?.fontSize || 12}
fontFamily={style?.fontFamily || 'Arial'}
fill="white"
width={40}
align="right"
/>
)}
</Group>
);
})}
</Group>
);
};
export default FunnelComponent;
@@ -0,0 +1,100 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, test, expect, vi } from 'vitest';
import { Stage, Layer } from 'react-konva';
import ChartComponent from '../ChartComponent';
import { ChartData } from '@/types/mockup';
// Mock Konva as it doesn't work well in testing environments
vi.mock('react-konva', () => {
return {
Stage: ({ children }: { children: React.ReactNode }) => <div data-testid="stage">{children}</div>,
Layer: ({ children }: { children: React.ReactNode }) => <div data-testid="layer">{children}</div>,
Group: ({ children }: { children: React.ReactNode }) => <div data-testid="group">{children}</div>,
Rect: () => <div data-testid="rect"></div>,
Line: () => <div data-testid="line"></div>,
Text: ({ text }: { text: string }) => <div data-testid="text">{text}</div>,
}
});
describe('ChartComponent', () => {
const mockChartData: ChartData = {
title: 'Test Chart',
labels: ['Jan', 'Feb', 'Mar'],
datasets: [
{
label: 'Dataset 1',
data: [10, 20, 30],
backgroundColor: '#4caf50'
}
]
};
const defaultProps = {
id: 'chart-1',
position: { x: 10, y: 10 },
size: { width: 400, height: 300 },
style: { backgroundColor: 'white', fontSize: 12 },
data: mockChartData,
isSelected: false,
onClick: vi.fn()
};
test('renders chart with correct data', () => {
render(
<Stage>
<Layer>
<ChartComponent {...defaultProps} />
</Layer>
</Stage>
);
// Check if chart title is rendered
expect(screen.queryByText('Test Chart')).toBeTruthy();
// Check if all labels are rendered
expect(screen.queryByText('Jan')).toBeTruthy();
expect(screen.queryByText('Feb')).toBeTruthy();
expect(screen.queryByText('Mar')).toBeTruthy();
// Check if data values are rendered
expect(screen.queryByText('10')).toBeTruthy();
expect(screen.queryByText('20')).toBeTruthy();
expect(screen.queryByText('30')).toBeTruthy();
// Check chart structure - we can't use getAllByTestId with the mocked components
const rects = document.querySelectorAll('[data-testid="rect"]');
const lines = document.querySelectorAll('[data-testid="line"]');
expect(rects.length).toBeGreaterThan(0);
expect(lines.length).toBe(2); // X and Y axis
});
test('handles undefined size gracefully', () => {
const propsWithoutSize = { ...defaultProps, size: undefined };
const { container } = render(
<Stage>
<Layer>
<ChartComponent {...propsWithoutSize} />
</Layer>
</Stage>
);
// Component should return null if size is undefined
expect(container.querySelector('[data-testid="group"]')).toBeNull();
});
test('handles selection state correctly', () => {
const selectedProps = { ...defaultProps, isSelected: true };
render(
<Stage>
<Layer>
<ChartComponent {...selectedProps} />
</Layer>
</Stage>
);
// When selected, we should have an extra rect for the selection indicator
const rects = document.querySelectorAll('[data-testid="rect"]');
expect(rects.length).toBeGreaterThan(1);
});
});