First push
This commit is contained in:
1 parent
db25d6dbda
commit
7299578599
48 files changed
+17513
-1
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user