NextGraph integration (WIP), broker banner, and feature-based architecture
- Add NextGraph data layer with @ng-org/orm, SHEX shapes (Event, UserProfile, Participation), session management, and FestipodDataContext with dual-mode operation (connected via NextGraph or local seed data) - Add BrokerBanner and NgStatus components showing connection status - Refactor to feature-based architecture: organize code by business domain (event, user, home, auth, workshop, meeting, notification) instead of technical layer. Modules only import from shared/, never from each other - Collocate BDD features and step definitions with their modules: event-specific steps in event/steps/, user steps in user/steps/, shared generic steps remain in shared/steps/ - Set up multi-layer BDD structure (frontend/backend/e2e steps per module) - Add project documentation (AGENTS.md, .project/knowledge/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { Gallery } from './components/Gallery';
|
||||
import { DemoMode } from './components/DemoMode';
|
||||
import { SpecsPage } from './components/specs';
|
||||
|
||||
function AppContent() {
|
||||
const { route, navigate, goBack } = useRouter();
|
||||
|
||||
if (route.page === 'demo') {
|
||||
return (
|
||||
<DemoMode
|
||||
initialScreenId={route.screenId}
|
||||
onBack={goBack}
|
||||
onNavigateToStory={(storyId) => navigate({ page: 'specs', storyId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (route.page === 'specs') {
|
||||
return (
|
||||
<SpecsPage
|
||||
selectedFeatureId={route.featureId}
|
||||
selectedStoryId={route.storyId}
|
||||
onBack={goBack}
|
||||
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
|
||||
onSelectStory={(storyId) => navigate({ page: 'specs', storyId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Gallery
|
||||
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
|
||||
onShowSpecs={() => navigate({ page: 'specs' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<AppContent />
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,426 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PhoneFrame, BrokerBanner } from '../../shared/components/sketchy';
|
||||
import { screens, getScreen } from '../../screens';
|
||||
import { getStoriesForScreen, categoryLabels, categoryColors, priorityColors } from '../../shared/data';
|
||||
import { getStoryUrl } from '../router';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface DemoModeProps {
|
||||
initialScreenId: string;
|
||||
onBack: () => void;
|
||||
onNavigateToStory: (storyId: string) => void;
|
||||
}
|
||||
|
||||
export function DemoMode({ initialScreenId, onBack, onNavigateToStory }: DemoModeProps) {
|
||||
const [currentScreenId, setCurrentScreenId] = useState(initialScreenId);
|
||||
const [history, setHistory] = useState<string[]>([initialScreenId]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const currentScreen = getScreen(currentScreenId);
|
||||
const ScreenComponent = currentScreen?.component;
|
||||
const linkedStories = getStoriesForScreen(currentScreenId);
|
||||
const isConnectedScreen = !['welcome', 'login'].includes(currentScreenId);
|
||||
|
||||
const navigate = (screenId: string) => {
|
||||
const newHistory = [...history.slice(0, historyIndex + 1), screenId];
|
||||
setHistory(newHistory);
|
||||
setHistoryIndex(newHistory.length - 1);
|
||||
setCurrentScreenId(screenId);
|
||||
};
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < history.length - 1;
|
||||
|
||||
const goBack = () => {
|
||||
if (canGoBack) {
|
||||
const newIndex = historyIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
const screenId = history[newIndex];
|
||||
if (screenId) setCurrentScreenId(screenId);
|
||||
}
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
if (canGoForward) {
|
||||
const newIndex = historyIndex + 1;
|
||||
setHistoryIndex(newIndex);
|
||||
const screenId = history[newIndex];
|
||||
if (screenId) setCurrentScreenId(screenId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100vh',
|
||||
background: 'var(--tool-bg)',
|
||||
overflow: 'hidden',
|
||||
transition: 'background-color 0.2s ease',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Mobile overlay */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<div style={{
|
||||
width: 280,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'transform 0.3s ease, background-color 0.2s ease, border-color 0.2s ease',
|
||||
...(isMobile ? {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
|
||||
} : {}),
|
||||
}}>
|
||||
{/* Back button and theme toggle */}
|
||||
<div style={{ padding: 16, borderBottom: '1px solid var(--tool-border-light)', display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 16px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
← Galerie
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Current screen & navigation */}
|
||||
<div style={{ padding: 16, borderBottom: '1px solid var(--tool-border-light)' }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: 'var(--tool-text-muted)',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
Écran actuel
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{currentScreen?.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={goBack}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
opacity: canGoBack ? 1 : 0.4,
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: canGoBack ? 'pointer' : 'default',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
‹ Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={goForward}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
opacity: canGoForward ? 1 : 0.4,
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: canGoForward ? 'pointer' : 'default',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
Suivant ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Stories for this screen */}
|
||||
{linkedStories.length > 0 && (
|
||||
<div style={{
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
maxHeight: '40%',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: 'var(--tool-text-muted)',
|
||||
padding: '12px 16px 8px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--tool-surface)',
|
||||
}}>
|
||||
User Stories ({linkedStories.length})
|
||||
</div>
|
||||
{linkedStories.map((story) => (
|
||||
<a
|
||||
key={story.id}
|
||||
href={getStoryUrl(story.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigateToStory(story.id);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
textDecoration: 'none',
|
||||
color: 'var(--tool-text)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
background: priorityColors[story.priority],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 9,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
P{story.priority}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
background: categoryColors[story.category],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 9,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
{categoryLabels[story.category]}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{story.title}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screen list */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '8px 0',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: 'var(--tool-text-muted)',
|
||||
padding: '8px 16px',
|
||||
}}>
|
||||
Tous les écrans
|
||||
</div>
|
||||
{screens.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => navigate(s.id)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
background: s.id === currentScreenId ? 'var(--tool-border-light)' : 'transparent',
|
||||
borderLeft: s.id === currentScreenId ? '3px solid var(--tool-text)' : '3px solid transparent',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone preview area */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Mobile header */}
|
||||
{isMobile && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
☰ Menu
|
||||
</button>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--tool-text)',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{currentScreen?.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: isMobile ? 12 : 24,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
maxHeight: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: 'scale(var(--phone-scale, 1))',
|
||||
transformOrigin: 'center center',
|
||||
}}>
|
||||
<ScaledPhoneFrame isMobile={isMobile}>
|
||||
{isConnectedScreen && <BrokerBanner />}
|
||||
{ScreenComponent && <ScreenComponent navigate={navigate} />}
|
||||
</ScaledPhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScaledPhoneFrame({ children, isMobile = false }: { children: React.ReactNode; isMobile?: boolean }) {
|
||||
const phoneWidth = 375;
|
||||
const phoneHeight = 812;
|
||||
|
||||
// Calculate scale to fit in viewport with some padding
|
||||
const [scale, setScale] = React.useState(1);
|
||||
|
||||
React.useEffect(() => {
|
||||
const calculateScale = () => {
|
||||
const mobileHeaderHeight = isMobile ? 56 : 0;
|
||||
const padding = isMobile ? 24 : 48;
|
||||
const sidebarWidth = isMobile ? 0 : 280;
|
||||
|
||||
const availableHeight = window.innerHeight - padding - mobileHeaderHeight;
|
||||
const availableWidth = window.innerWidth - sidebarWidth - padding;
|
||||
|
||||
const scaleByHeight = availableHeight / phoneHeight;
|
||||
const scaleByWidth = availableWidth / phoneWidth;
|
||||
|
||||
const newScale = Math.min(scaleByHeight, scaleByWidth, 1);
|
||||
setScale(Math.max(0.4, newScale)); // minimum 40% scale for mobile
|
||||
};
|
||||
|
||||
calculateScale();
|
||||
window.addEventListener('resize', calculateScale);
|
||||
return () => window.removeEventListener('resize', calculateScale);
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: phoneWidth * scale,
|
||||
height: phoneHeight * scale,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
width: phoneWidth,
|
||||
height: phoneHeight,
|
||||
}}>
|
||||
<PhoneFrame>
|
||||
{children}
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PhoneFrame, BrokerBanner } from '../../shared/components/sketchy';
|
||||
import { screenGroups, type Screen } from '../../screens';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface GalleryProps {
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onShowSpecs: () => void;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.32;
|
||||
const MAX_SCALE = 0.75;
|
||||
const DEFAULT_SCALE = 0.5;
|
||||
|
||||
export function Gallery({ onSelectScreen, onShowSpecs }: GalleryProps) {
|
||||
const [scale, setScale] = useState(DEFAULT_SCALE);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
padding: isMobile ? '16px' : '24px 32px',
|
||||
borderBottom: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'flex-start',
|
||||
gap: isMobile ? 16 : 0,
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 24 : 28,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
Festipod
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '8px 0 0 0',
|
||||
}}>
|
||||
Cliquez sur un écran pour le prévisualiser
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: isMobile ? 8 : 24,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Specs BDD button */}
|
||||
<button
|
||||
onClick={onShowSpecs}
|
||||
style={{
|
||||
background: 'var(--tool-text)',
|
||||
color: 'var(--tool-bg)',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Specs BDD
|
||||
</button>
|
||||
|
||||
{/* Zoom control - hide on mobile */}
|
||||
{!isMobile && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, color: 'var(--tool-text-muted)' }}>Zoom</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SCALE * 100}
|
||||
max={MAX_SCALE * 100}
|
||||
value={scale * 100}
|
||||
onChange={(e) => setScale(Number(e.target.value) / 100)}
|
||||
style={{
|
||||
width: 100,
|
||||
accentColor: 'var(--tool-text)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 14, width: 40 }}>{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
|
||||
{screenGroups.map((group) => (
|
||||
<div key={group.id} style={{ marginBottom: isMobile ? 24 : 32 }}>
|
||||
{/* Group header */}
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 16 : 18,
|
||||
margin: isMobile ? '0 0 12px 16px' : '0 0 16px 32px',
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{group.name}
|
||||
</h2>
|
||||
|
||||
{/* Horizontal scrolling row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: isMobile ? 12 : 24,
|
||||
paddingLeft: isMobile ? 16 : 32,
|
||||
paddingRight: isMobile ? 16 : 32,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 8,
|
||||
}}>
|
||||
{group.screens.map((screen) => (
|
||||
<GalleryItem
|
||||
key={screen.id}
|
||||
screen={screen}
|
||||
scale={isMobile ? 0.35 : scale}
|
||||
onClick={() => onSelectScreen(screen.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GalleryItemProps {
|
||||
screen: Screen;
|
||||
scale: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NON_CONNECTED_SCREENS = ['welcome', 'login'];
|
||||
|
||||
function GalleryItem({ screen, scale, onClick }: GalleryItemProps) {
|
||||
const ScreenComponent = screen.component;
|
||||
const phoneWidth = 375;
|
||||
const phoneHeight = 812;
|
||||
const isConnected = !NON_CONNECTED_SCREENS.includes(screen.id);
|
||||
|
||||
return (
|
||||
<div className="gallery-item" onClick={onClick} style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: phoneWidth * scale,
|
||||
height: phoneHeight * scale,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
width: phoneWidth,
|
||||
height: phoneHeight,
|
||||
}}>
|
||||
<PhoneFrame>
|
||||
{isConnected && <BrokerBanner />}
|
||||
<ScreenComponent navigate={() => {}} />
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{screen.name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../shared/context/ThemeContext';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'system') setTheme('light');
|
||||
else if (theme === 'light') setTheme('dark');
|
||||
else setTheme('system');
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return <Sun size={18} />;
|
||||
case 'dark':
|
||||
return <Moon size={18} />;
|
||||
case 'system':
|
||||
return <Monitor size={18} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return 'Clair';
|
||||
case 'dark':
|
||||
return 'Sombre';
|
||||
case 'system':
|
||||
return 'Auto';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
title={`Mode: ${getLabel()} (cliquez pour changer)`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{getIcon()}
|
||||
<span>{getLabel()}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
userStories,
|
||||
categoryLabels,
|
||||
categoryColors,
|
||||
priorityLabels,
|
||||
priorityColors,
|
||||
getScreenIdsWithStories,
|
||||
type UserStory,
|
||||
type StoryCategory,
|
||||
} from '../../shared/data';
|
||||
import { getScreen, screens } from '../../screens';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface UserStoriesPageProps {
|
||||
selectedStoryId?: string;
|
||||
onBack: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
}
|
||||
|
||||
const categories: StoryCategory[] = ['WORKSHOP', 'EVENT', 'USER', 'MEETING', 'NOTIF'];
|
||||
|
||||
export function UserStoriesPage({ selectedStoryId, onBack, onSelectScreen }: UserStoriesPageProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<StoryCategory>>(new Set());
|
||||
const [selectedPriorities, setSelectedPriorities] = useState<Set<number>>(new Set());
|
||||
const [selectedScreens, setSelectedScreens] = useState<Set<string>>(new Set());
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const storyRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Scroll to selected story on mount
|
||||
useEffect(() => {
|
||||
if (selectedStoryId) {
|
||||
const element = storyRefs.current.get(selectedStoryId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [selectedStoryId]);
|
||||
|
||||
// Get screens that have linked stories
|
||||
const screensWithStories = useMemo(() => {
|
||||
const screenIds = getScreenIdsWithStories();
|
||||
return screens.filter(s => screenIds.includes(s.id));
|
||||
}, []);
|
||||
|
||||
// Filter stories
|
||||
const filteredStories = useMemo(() => {
|
||||
return userStories.filter(story => {
|
||||
if (selectedCategories.size > 0 && !selectedCategories.has(story.category)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedPriorities.size > 0 && !selectedPriorities.has(story.priority)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedScreens.size > 0 && !story.screenIds.some(id => selectedScreens.has(id))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectedCategories, selectedPriorities, selectedScreens]);
|
||||
|
||||
const storiesByPriority = [0, 1, 2, 3].map(priority => ({
|
||||
priority,
|
||||
stories: filteredStories.filter(s => s.priority === priority),
|
||||
})).filter(({ stories }) => stories.length > 0);
|
||||
|
||||
const toggleCategory = (cat: StoryCategory) => {
|
||||
const newSet = new Set(selectedCategories);
|
||||
if (newSet.has(cat)) {
|
||||
newSet.delete(cat);
|
||||
} else {
|
||||
newSet.add(cat);
|
||||
}
|
||||
setSelectedCategories(newSet);
|
||||
};
|
||||
|
||||
const togglePriority = (p: number) => {
|
||||
const newSet = new Set(selectedPriorities);
|
||||
if (newSet.has(p)) {
|
||||
newSet.delete(p);
|
||||
} else {
|
||||
newSet.add(p);
|
||||
}
|
||||
setSelectedPriorities(newSet);
|
||||
};
|
||||
|
||||
const toggleScreen = (screenId: string) => {
|
||||
const newSet = new Set(selectedScreens);
|
||||
if (newSet.has(screenId)) {
|
||||
newSet.delete(screenId);
|
||||
} else {
|
||||
newSet.add(screenId);
|
||||
}
|
||||
setSelectedScreens(newSet);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedCategories(new Set());
|
||||
setSelectedPriorities(new Set());
|
||||
setSelectedScreens(new Set());
|
||||
};
|
||||
|
||||
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || selectedScreens.size > 0;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--tool-bg)', transition: 'background-color 0.2s ease' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: isMobile ? '16px' : '24px 32px',
|
||||
borderBottom: '2px solid var(--tool-border)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: isMobile ? 12 : 0,
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: isMobile ? 'flex-start' : 'center', gap: isMobile ? 12 : 16, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: isMobile ? '100%' : 'auto', justifyContent: 'space-between' }}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: isMobile ? '6px 12px' : '8px 16px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
{isMobile && <ThemeToggle />}
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 22 : 28,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
User Stories
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: isMobile ? 13 : 16,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '8px 0 0 0',
|
||||
}}>
|
||||
{filteredStories.length} / {userStories.length} stories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isMobile && <ThemeToggle />}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{isMobile ? (
|
||||
/* Mobile: Collapsible filter bar */
|
||||
<div style={{
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
{/* Filter toggle button */}
|
||||
<button
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>☰ Filtres</span>
|
||||
{hasFilters && (
|
||||
<span style={{
|
||||
background: 'var(--tool-text)',
|
||||
color: 'var(--tool-bg)',
|
||||
borderRadius: '50%',
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
}}>
|
||||
{selectedCategories.size + selectedPriorities.size}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{filtersExpanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* Expandable filter panel */}
|
||||
{filtersExpanded && (
|
||||
<div style={{
|
||||
padding: '0 16px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
borderTop: '1px solid var(--tool-border-light)',
|
||||
paddingTop: 12,
|
||||
}}>
|
||||
{/* Category filters */}
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 11,
|
||||
color: 'var(--tool-text-muted)',
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
Catégorie
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
label={categoryLabels[cat]}
|
||||
color={categoryColors[cat]}
|
||||
selected={selectedCategories.has(cat)}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 11,
|
||||
color: 'var(--tool-text-muted)',
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
Priorité
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<FilterChip
|
||||
key={p}
|
||||
label={`P${p}`}
|
||||
color={priorityColors[p] ?? '#888'}
|
||||
selected={selectedPriorities.has(p)}
|
||||
onClick={() => togglePriority(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
color: '#c00',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop: Full filter bar */
|
||||
<div style={{
|
||||
padding: '16px 32px',
|
||||
borderBottom: '1px solid var(--tool-border-light)',
|
||||
background: 'var(--tool-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
transition: 'background-color 0.2s ease, border-color 0.2s ease',
|
||||
}}>
|
||||
{/* Category filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Catégorie
|
||||
</span>
|
||||
{categories.map(cat => (
|
||||
<FilterChip
|
||||
key={cat}
|
||||
label={categoryLabels[cat]}
|
||||
color={categoryColors[cat]}
|
||||
selected={selectedCategories.has(cat)}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Priorité
|
||||
</span>
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<FilterChip
|
||||
key={p}
|
||||
label={`P${p} ${priorityLabels[p]}`}
|
||||
color={priorityColors[p] ?? '#888'}
|
||||
selected={selectedPriorities.has(p)}
|
||||
onClick={() => togglePriority(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
minWidth: 70,
|
||||
}}>
|
||||
Écran
|
||||
</span>
|
||||
{screensWithStories.map(screen => (
|
||||
<FilterChip
|
||||
key={screen.id}
|
||||
label={screen.name}
|
||||
color="var(--tool-text)"
|
||||
selected={selectedScreens.has(screen.id)}
|
||||
onClick={() => toggleScreen(screen.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: '#c00',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stories by priority */}
|
||||
<div style={{ padding: isMobile ? 16 : 32 }}>
|
||||
{storiesByPriority.length === 0 ? (
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
color: 'var(--tool-text-muted)',
|
||||
textAlign: 'center',
|
||||
padding: 40,
|
||||
}}>
|
||||
Aucune story ne correspond aux filtres sélectionnés
|
||||
</p>
|
||||
) : (
|
||||
storiesByPriority.map(({ priority, stories }) => (
|
||||
<div key={priority} style={{ marginBottom: 40 }}>
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 20,
|
||||
margin: '0 0 16px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
background: priorityColors[priority],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
P{priority}
|
||||
</span>
|
||||
Priorité {priorityLabels[priority]}
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--tool-text-muted)',
|
||||
fontWeight: 'normal',
|
||||
}}>
|
||||
({stories.length} stories)
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{stories.map(story => (
|
||||
<StoryCard
|
||||
key={story.id}
|
||||
ref={(el) => {
|
||||
if (el) storyRefs.current.set(story.id, el);
|
||||
}}
|
||||
story={story}
|
||||
isSelected={story.id === selectedStoryId}
|
||||
onSelectScreen={onSelectScreen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterChipProps {
|
||||
label: string;
|
||||
color: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function FilterChip({ label, color, selected, onClick }: FilterChipProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: selected ? color : 'transparent',
|
||||
color: selected ? 'white' : color,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '4px 10px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StoryCardProps {
|
||||
story: UserStory;
|
||||
isSelected: boolean;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
}
|
||||
|
||||
const StoryCard = React.forwardRef<HTMLDivElement, StoryCardProps>(
|
||||
function StoryCard({ story, isSelected, onSelectScreen }, ref) {
|
||||
const linkedScreens = story.screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(({ screen }) => screen !== undefined);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
border: isSelected ? '3px solid #2563eb' : '2px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: 16,
|
||||
background: isSelected ? '#eff6ff' : 'var(--tool-surface)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 8 }}>
|
||||
{/* Category badge */}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
background: categoryColors[story.category],
|
||||
color: 'white',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{categoryLabels[story.category]}
|
||||
</span>
|
||||
<h3 style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 16,
|
||||
margin: 0,
|
||||
color: 'var(--tool-text)',
|
||||
}}>
|
||||
{story.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
margin: '0 0 12px 0',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{story.description}
|
||||
</p>
|
||||
|
||||
{linkedScreens.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{linkedScreens.map(({ id, screen }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onSelectScreen(id)}
|
||||
style={{
|
||||
background: 'var(--tool-border-light)',
|
||||
border: '1px solid var(--tool-border)',
|
||||
borderRadius: '255px 15px 225px 15px/15px 225px 15px 255px',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
→ {screen!.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-sketch)',
|
||||
fontSize: 13,
|
||||
color: 'var(--tool-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
margin: 0,
|
||||
}}>
|
||||
Pas encore de mockup
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Input } from '../../../shared/components/ui/input';
|
||||
import { Button } from '../../../shared/components/ui/button';
|
||||
import { ChevronDown, ChevronUp, Filter } from 'lucide-react';
|
||||
import { categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../../shared/data';
|
||||
|
||||
const categories: StoryCategory[] = ['WORKSHOP', 'EVENT', 'USER', 'MEETING', 'NOTIF'];
|
||||
|
||||
function useIsMobile(breakpoint = 640) {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < breakpoint);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
interface ScreenInfo {
|
||||
id: string;
|
||||
screen: { id: string; name: string } | undefined;
|
||||
}
|
||||
|
||||
interface FeatureFilterProps {
|
||||
selectedCategories: Set<string>;
|
||||
onCategoriesChange: (categories: Set<string>) => void;
|
||||
selectedPriorities: Set<number>;
|
||||
onPrioritiesChange: (priorities: Set<number>) => void;
|
||||
selectedScreens: Set<string>;
|
||||
onScreensChange: (screens: Set<string>) => void;
|
||||
screensWithStories: ScreenInfo[];
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureFilter({
|
||||
selectedCategories,
|
||||
onCategoriesChange,
|
||||
selectedPriorities,
|
||||
onPrioritiesChange,
|
||||
selectedScreens,
|
||||
onScreensChange,
|
||||
screensWithStories,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: FeatureFilterProps) {
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
const newSet = new Set(selectedCategories);
|
||||
if (newSet.has(cat)) {
|
||||
newSet.delete(cat);
|
||||
} else {
|
||||
newSet.add(cat);
|
||||
}
|
||||
onCategoriesChange(newSet);
|
||||
};
|
||||
|
||||
const togglePriority = (p: number) => {
|
||||
const newSet = new Set(selectedPriorities);
|
||||
if (newSet.has(p)) {
|
||||
newSet.delete(p);
|
||||
} else {
|
||||
newSet.add(p);
|
||||
}
|
||||
onPrioritiesChange(newSet);
|
||||
};
|
||||
|
||||
const toggleScreen = (screenId: string) => {
|
||||
const newSet = new Set(selectedScreens);
|
||||
if (newSet.has(screenId)) {
|
||||
newSet.delete(screenId);
|
||||
} else {
|
||||
newSet.add(screenId);
|
||||
}
|
||||
onScreensChange(newSet);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
onCategoriesChange(new Set());
|
||||
onPrioritiesChange(new Set());
|
||||
onScreensChange(new Set());
|
||||
onSearchChange('');
|
||||
};
|
||||
|
||||
const hasFilters = selectedCategories.size > 0 || selectedPriorities.size > 0 || selectedScreens.size > 0 || searchQuery;
|
||||
const activeFilterCount = selectedCategories.size + selectedPriorities.size + selectedScreens.size + (searchQuery ? 1 : 0);
|
||||
|
||||
// On mobile, show compact filter bar with expand button
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30">
|
||||
{/* Compact header with search and filter toggle */}
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-background text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={activeFilterCount > 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||
className="shrink-0 h-9"
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
{activeFilterCount > 0 ? activeFilterCount : ''}
|
||||
{filtersExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expandable filter panel */}
|
||||
{filtersExpanded && (
|
||||
<div className="px-4 pb-3 space-y-3 border-t border-border/50 pt-3">
|
||||
{/* Category filters */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Catégorie</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategories.has(cat) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
style={{
|
||||
backgroundColor: selectedCategories.has(cat) ? categoryColors[cat] : 'transparent',
|
||||
borderColor: categoryColors[cat],
|
||||
color: selectedCategories.has(cat) ? 'white' : categoryColors[cat],
|
||||
}}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Priorité</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={selectedPriorities.has(p) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
style={{
|
||||
backgroundColor: selectedPriorities.has(p) ? priorityColors[p] : 'transparent',
|
||||
borderColor: priorityColors[p],
|
||||
color: selectedPriorities.has(p) ? 'white' : priorityColors[p],
|
||||
}}
|
||||
onClick={() => togglePriority(p)}
|
||||
>
|
||||
P{p}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
{screensWithStories.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block mb-2">Écran</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{screensWithStories.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={selectedScreens.has(id) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="text-xs px-2 h-7"
|
||||
onClick={() => toggleScreen(id)}
|
||||
>
|
||||
{screen?.name || id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-destructive hover:text-destructive text-xs p-0 h-auto">
|
||||
Effacer les filtres
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="border-b border-border bg-muted/30 px-8 py-4 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="max-w-md">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Rechercher une fonctionnalité..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Catégorie</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategories.has(cat) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
style={{
|
||||
backgroundColor: selectedCategories.has(cat) ? categoryColors[cat] : 'transparent',
|
||||
borderColor: categoryColors[cat],
|
||||
color: selectedCategories.has(cat) ? 'white' : categoryColors[cat],
|
||||
}}
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Priorité</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[0, 1, 2, 3].map(p => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={selectedPriorities.has(p) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
style={{
|
||||
backgroundColor: selectedPriorities.has(p) ? priorityColors[p] : 'transparent',
|
||||
borderColor: priorityColors[p],
|
||||
color: selectedPriorities.has(p) ? 'white' : priorityColors[p],
|
||||
}}
|
||||
onClick={() => togglePriority(p)}
|
||||
>
|
||||
P{p} - {priorityLabels[p]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screen filters */}
|
||||
{screensWithStories.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground w-20 shrink-0">Écran</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{screensWithStories.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant={selectedScreens.has(id) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => toggleScreen(id)}
|
||||
>
|
||||
{screen?.name || id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasFilters && (
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-destructive hover:text-destructive">
|
||||
Effacer les filtres
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import type { ParsedFeature } from '../../../shared/types/gherkin';
|
||||
import { getStoryById, categoryLabels, categoryColors, priorityLabels, priorityColors, type StoryCategory } from '../../../shared/data';
|
||||
import { getTestStatus, getScenarioResults } from '../../../shared/data/testResults';
|
||||
import { getScreen } from '../../../screens';
|
||||
import { GherkinHighlighter } from './GherkinHighlighter';
|
||||
import { Button } from '../../../shared/components/ui/button';
|
||||
import { ArrowLeft, Monitor, CheckCircle2, XCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface FeatureViewProps {
|
||||
feature: ParsedFeature;
|
||||
onBack: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onSelectStory: (storyId: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureView({ feature, onBack, onSelectScreen, onSelectStory }: FeatureViewProps) {
|
||||
const linkedStory = getStoryById(feature.id);
|
||||
const linkedScreens = linkedStory?.screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(s => s.screen) || [];
|
||||
const testStatus = getTestStatus(feature.id);
|
||||
const scenarioResults = getScenarioResults(feature.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 sm:px-8 py-6 bg-card">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Retour
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Test Results - Compact in header */}
|
||||
{testStatus && (
|
||||
<div className="flex items-center gap-3">
|
||||
{testStatus.failed > 0 ? (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
) : testStatus.skipped > 0 ? (
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-green-600 font-medium">{testStatus.passed} passes</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-red-600 font-medium">{testStatus.failed} echecs</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-yellow-600 font-medium">{testStatus.skipped} ignores</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span
|
||||
className="px-3 py-1 text-sm font-medium text-white rounded-md"
|
||||
style={{ backgroundColor: priorityColors[feature.priority] }}
|
||||
>
|
||||
P{feature.priority} - {priorityLabels[feature.priority]}
|
||||
</span>
|
||||
<span
|
||||
className="px-3 py-1 text-sm font-medium text-white rounded-md"
|
||||
style={{ backgroundColor: categoryColors[feature.category as StoryCategory] }}
|
||||
>
|
||||
{categoryLabels[feature.category as StoryCategory]}
|
||||
</span>
|
||||
{linkedStory ? (
|
||||
<button
|
||||
onClick={() => onSelectStory(linkedStory.id)}
|
||||
className="text-sm text-primary font-mono hover:underline cursor-pointer"
|
||||
>
|
||||
{feature.id.toUpperCase()}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{feature.id.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{feature.name.replace(/^US-\d+\s*/, '')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-8 py-6">
|
||||
{/* Linked screens - inline buttons */}
|
||||
{linkedScreens.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Écrans:</span>
|
||||
{linkedScreens.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSelectScreen(id)}
|
||||
>
|
||||
{screen?.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content - Gherkin */}
|
||||
<GherkinHighlighter
|
||||
content={feature.rawContent}
|
||||
scenarioResults={scenarioResults}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Code2, CheckCircle2, XCircle, AlertCircle, Clock, Table2 } from 'lucide-react';
|
||||
import { Button } from '../../../shared/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '../../../shared/components/ui/card';
|
||||
import { findStepDefinition, type StepDefinitionInfo } from '../../../shared/data/stepDefinitions';
|
||||
|
||||
interface ScenarioResult {
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'skipped' | 'unknown';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface GherkinHighlighterProps {
|
||||
content: string;
|
||||
scenarioResults?: ScenarioResult[];
|
||||
}
|
||||
|
||||
interface ParsedBlock {
|
||||
type: 'header' | 'background' | 'scenario';
|
||||
lines: string[];
|
||||
startLine: number;
|
||||
name?: string;
|
||||
status?: 'passed' | 'failed' | 'skipped' | 'unknown';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const keywords = {
|
||||
feature: ['Fonctionnalité:', 'Feature:'],
|
||||
background: ['Contexte:', 'Background:'],
|
||||
scenario: ['Scénario:', 'Scenario:', 'Plan du Scénario:', 'Scenario Outline:'],
|
||||
given: ['Étant donné que ', "Étant donné qu'", 'Étant donné', 'Etant donné que ', "Etant donné qu'", 'Etant donné', 'Given', 'Soit'],
|
||||
when: ['Quand', 'When', 'Lorsque'],
|
||||
then: ['Alors', 'Then'],
|
||||
and: ['Et', 'And', 'Mais', 'But', '* '],
|
||||
examples: ['Exemples:', 'Examples:'],
|
||||
};
|
||||
|
||||
// Placeholder step text for skipped/not-implemented scenarios
|
||||
const SKIP_PLACEHOLDER = 'Scénario non implémenté';
|
||||
|
||||
export function GherkinHighlighter({ content, scenarioResults = [] }: GherkinHighlighterProps) {
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Parse content into blocks
|
||||
const blocks = useMemo(() => parseBlocks(lines, scenarioResults), [lines, scenarioResults]);
|
||||
|
||||
// Determine initial collapsed state - scenarios collapsed by default (open if failed), background always open
|
||||
const initialCollapsed = useMemo(() => {
|
||||
const state: Record<number, boolean> = {};
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type === 'scenario') {
|
||||
state[index] = block.status !== 'failed';
|
||||
} else if (block.type === 'background') {
|
||||
// Background is always expanded
|
||||
state[index] = false;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}, [blocks]);
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>(initialCollapsed);
|
||||
const [showDefinitions, setShowDefinitions] = useState(true);
|
||||
|
||||
const toggleBlock = (index: number) => {
|
||||
setCollapsed(prev => ({ ...prev, [index]: !prev[index] }));
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
blocks.forEach((_, index) => {
|
||||
newState[index] = false;
|
||||
});
|
||||
setCollapsed(newState);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type === 'scenario') {
|
||||
newState[index] = true;
|
||||
}
|
||||
// Background stays expanded
|
||||
});
|
||||
setCollapsed(newState);
|
||||
};
|
||||
|
||||
const scenarioCount = blocks.filter(b => b.type === 'scenario').length;
|
||||
const collapsedScenarioCount = blocks.filter((b, i) => b.type === 'scenario' && collapsed[i]).length;
|
||||
const allCollapsed = collapsedScenarioCount === scenarioCount;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" style={{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={allCollapsed ? expandAll : collapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{allCollapsed ? (
|
||||
<>
|
||||
<ChevronsUpDown className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Tout déplier</span>
|
||||
<span className="sm:hidden">Déplier</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronsDownUp className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Tout replier</span>
|
||||
<span className="sm:hidden">Replier</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showDefinitions ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowDefinitions(!showDefinitions)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Code2 className="w-3.5 h-3.5 mr-1" />
|
||||
<span className="hidden sm:inline">Définitions</span>
|
||||
<span className="sm:hidden">Déf.</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* All Blocks including header */}
|
||||
{blocks.map((block, blockIndex) => (
|
||||
<BlockRenderer
|
||||
key={blockIndex}
|
||||
block={block}
|
||||
isCollapsed={collapsed[blocks.indexOf(block)] ?? false}
|
||||
onToggle={() => toggleBlock(blocks.indexOf(block))}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseBlocks(lines: string[], scenarioResults: ScenarioResult[]): ParsedBlock[] {
|
||||
const blocks: ParsedBlock[] = [];
|
||||
let currentBlock: ParsedBlock | null = null;
|
||||
|
||||
const resultMap = new Map(scenarioResults.map(r => [r.name.toLowerCase().trim(), { status: r.status, errorMessage: r.errorMessage }]));
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? '';
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for scenario start
|
||||
const isScenario = keywords.scenario.some(kw => trimmed.startsWith(kw));
|
||||
const isBackground = keywords.background.some(kw => trimmed.startsWith(kw));
|
||||
const isFeature = keywords.feature.some(kw => trimmed.startsWith(kw));
|
||||
|
||||
if (isFeature || (currentBlock === null && !isScenario && !isBackground)) {
|
||||
// Header content (tags, language, feature line, description)
|
||||
if (!currentBlock || currentBlock.type !== 'header') {
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
currentBlock = { type: 'header', lines: [], startLine: i };
|
||||
}
|
||||
currentBlock.lines.push(line);
|
||||
} else if (isBackground) {
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
currentBlock = {
|
||||
type: 'background',
|
||||
lines: [line],
|
||||
startLine: i,
|
||||
name: extractName(trimmed, keywords.background),
|
||||
status: 'unknown'
|
||||
};
|
||||
} else if (isScenario) {
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
const name = extractName(trimmed, keywords.scenario);
|
||||
const result = resultMap.get(name.toLowerCase().trim());
|
||||
currentBlock = {
|
||||
type: 'scenario',
|
||||
lines: [line],
|
||||
startLine: i,
|
||||
name,
|
||||
status: result?.status || 'unknown',
|
||||
errorMessage: result?.errorMessage
|
||||
};
|
||||
} else if (currentBlock) {
|
||||
currentBlock.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock) blocks.push(currentBlock);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractName(line: string, keywords: string[]): string {
|
||||
for (const kw of keywords) {
|
||||
if (line.startsWith(kw)) {
|
||||
return line.slice(kw.length).trim();
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
interface BlockRendererProps {
|
||||
block: ParsedBlock;
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
showDefinitions: boolean;
|
||||
}
|
||||
|
||||
function BlockRenderer({ block, isCollapsed, onToggle, showDefinitions }: BlockRendererProps) {
|
||||
if (block.type === 'header') {
|
||||
// Extract user story lines (En tant que, Je peux/Je veux, Afin de)
|
||||
const userStoryLines = block.lines.filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith('En tant qu') ||
|
||||
trimmed.startsWith('Je peux') ||
|
||||
trimmed.startsWith('Je veux') ||
|
||||
trimmed.startsWith('Et ') ||
|
||||
trimmed.startsWith('Afin ');
|
||||
});
|
||||
|
||||
if (userStoryLines.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-violet-500 bg-violet-50/50 dark:bg-violet-950/20">
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-0.5">
|
||||
{userStoryLines.map((line, index) => (
|
||||
<div key={index} className="text-sm text-foreground">
|
||||
{line.trim()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const restLines = block.lines.slice(1);
|
||||
const isBackground = block.type === 'background';
|
||||
|
||||
// Parse steps from rest lines
|
||||
let parsedSteps = parseStepsFromLines(restLines);
|
||||
|
||||
// For skipped scenarios, filter out the placeholder step
|
||||
if (block.status === 'skipped') {
|
||||
parsedSteps = parsedSteps.filter(step => step.text !== SKIP_PLACEHOLDER);
|
||||
}
|
||||
|
||||
// Determine border color based on status
|
||||
const borderColor = block.status === 'passed' ? 'border-l-green-500' :
|
||||
block.status === 'failed' ? 'border-l-red-500' :
|
||||
block.status === 'skipped' ? 'border-l-yellow-500' :
|
||||
isBackground ? 'border-l-zinc-400' : 'border-l-cyan-500';
|
||||
|
||||
// Status icon
|
||||
const StatusIcon = () => {
|
||||
if (!block.status || block.status === 'unknown') return null;
|
||||
if (block.status === 'passed') return <CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />;
|
||||
if (block.status === 'failed') return <XCircle className="w-4 h-4 text-red-500 shrink-0" />;
|
||||
if (block.status === 'skipped') return <AlertCircle className="w-4 h-4 text-yellow-500 shrink-0" />;
|
||||
return <Clock className="w-4 h-4 text-zinc-400 shrink-0" />;
|
||||
};
|
||||
|
||||
// Skipped scenarios are not expandable (no steps to show)
|
||||
const isExpandable = block.status !== 'skipped' && parsedSteps.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={`border-l-4 ${borderColor}`}>
|
||||
{/* Header - clickable only if expandable */}
|
||||
<CardHeader
|
||||
className={`p-2 ${isExpandable ? 'cursor-pointer hover:bg-muted/50' : ''} transition-colors`}
|
||||
onClick={isExpandable ? onToggle : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Show chevron only if expandable */}
|
||||
{isExpandable ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-4 shrink-0" /> // Spacer to maintain alignment
|
||||
)}
|
||||
<StatusIcon />
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded shrink-0 ${
|
||||
isBackground
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400'
|
||||
: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400'
|
||||
}`}>
|
||||
{isBackground ? 'Contexte' : 'Scénario'}
|
||||
</span>
|
||||
<span className="font-medium text-foreground text-sm truncate sm:whitespace-normal">
|
||||
{block.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* Show step count only if expandable */}
|
||||
{isExpandable && parsedSteps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">
|
||||
{parsedSteps.length} étapes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Collapsible content - only shown if expandable and not collapsed */}
|
||||
{isExpandable && !isCollapsed && (
|
||||
<CardContent className="pt-0 px-2 pb-2">
|
||||
<div className="space-y-0.5 ml-0 sm:ml-6">
|
||||
{parsedSteps.map((step, index) => (
|
||||
<StepRenderer
|
||||
key={index}
|
||||
step={step}
|
||||
showDefinitions={showDefinitions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error message for failed scenarios */}
|
||||
{block.status === 'failed' && block.errorMessage && (
|
||||
<div className="ml-0 sm:ml-6 mt-2 p-2 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Erreur:</div>
|
||||
<pre className="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap break-words font-mono overflow-x-auto">
|
||||
{block.errorMessage}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParsedStep {
|
||||
type: 'given' | 'when' | 'then' | 'and' | 'examples' | 'table' | 'other';
|
||||
keyword: string;
|
||||
text: string;
|
||||
originalLine: string;
|
||||
tableRows?: string[][];
|
||||
}
|
||||
|
||||
function parseStepsFromLines(lines: string[]): ParsedStep[] {
|
||||
const steps: ParsedStep[] = [];
|
||||
let currentStep: ParsedStep | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Check for table row
|
||||
if (trimmed.startsWith('|')) {
|
||||
const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
|
||||
if (currentStep) {
|
||||
if (!currentStep.tableRows) currentStep.tableRows = [];
|
||||
currentStep.tableRows.push(cells);
|
||||
} else {
|
||||
// Standalone table row (shouldn't happen, but handle it)
|
||||
steps.push({
|
||||
type: 'table',
|
||||
keyword: '',
|
||||
text: trimmed,
|
||||
originalLine: line,
|
||||
tableRows: [cells]
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for step keywords
|
||||
let matched = false;
|
||||
|
||||
for (const kw of keywords.given) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'given', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.when) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'when', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.then) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'then', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.and) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'and', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (const kw of keywords.examples) {
|
||||
if (trimmed.startsWith(kw)) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'examples', keyword: kw, text: trimmed.slice(kw.length).trim(), originalLine: line };
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched && trimmed) {
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { type: 'other', keyword: '', text: trimmed, originalLine: line };
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep) steps.push(currentStep);
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
interface StepRendererProps {
|
||||
step: ParsedStep;
|
||||
showDefinitions: boolean;
|
||||
}
|
||||
|
||||
function StepRenderer({ step, showDefinitions }: StepRendererProps) {
|
||||
// Always check for step definition to show dotted underline
|
||||
// Use step.text (without keyword) to match against step definition patterns
|
||||
const stepDef = step.type !== 'table' && step.type !== 'other' && step.type !== 'examples'
|
||||
? findStepDefinition(step.text)
|
||||
: null;
|
||||
|
||||
// Keyword colors
|
||||
const keywordColor = step.type === 'given' ? 'text-blue-600 dark:text-blue-400' :
|
||||
step.type === 'when' ? 'text-amber-600 dark:text-amber-400' :
|
||||
step.type === 'then' ? 'text-green-600 dark:text-green-400' :
|
||||
step.type === 'and' ? 'text-zinc-500 dark:text-zinc-400' :
|
||||
step.type === 'examples' ? 'text-purple-600 dark:text-purple-400' :
|
||||
'text-muted-foreground';
|
||||
|
||||
const keywordBg = step.type === 'given' ? 'bg-blue-50 dark:bg-blue-950/30' :
|
||||
step.type === 'when' ? 'bg-amber-50 dark:bg-amber-950/30' :
|
||||
step.type === 'then' ? 'bg-green-50 dark:bg-green-950/30' :
|
||||
step.type === 'and' ? 'bg-zinc-50 dark:bg-zinc-800/50' :
|
||||
step.type === 'examples' ? 'bg-purple-50 dark:bg-purple-950/30' :
|
||||
'';
|
||||
|
||||
if (step.type === 'table') {
|
||||
return (
|
||||
<div className="ml-2 sm:ml-4 my-2">
|
||||
<Table2 className="w-4 h-4 text-muted-foreground inline mr-2" />
|
||||
<span className="text-sm text-muted-foreground">{step.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show popover only when definitions mode is active, but always show dotted underline for steps with definitions
|
||||
const dottedUnderlineStyle = {
|
||||
borderBottom: '1.3px dashed',
|
||||
borderColor: 'rgb(161 161 170)', // zinc-400
|
||||
};
|
||||
const stepTextElement = stepDef ? (
|
||||
showDefinitions ? (
|
||||
<StepDefinitionPopover stepDef={stepDef}>
|
||||
{highlightStringsInText(step.text)}
|
||||
</StepDefinitionPopover>
|
||||
) : (
|
||||
<span style={dottedUnderlineStyle}>
|
||||
{highlightStringsInText(step.text)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>{highlightStringsInText(step.text)}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
<div className={`flex items-start gap-1.5 px-1.5 py-0.5 rounded ${keywordBg}`}>
|
||||
{step.keyword && (
|
||||
<span className={`font-medium text-sm shrink-0 ${keywordColor}`}>
|
||||
{step.keyword}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-foreground break-words">
|
||||
{stepTextElement}
|
||||
</span>
|
||||
</div>
|
||||
{/* Render table if present */}
|
||||
{step.tableRows && step.tableRows.length > 0 && (
|
||||
<div className="ml-0 sm:ml-4 mt-1 overflow-x-auto -mx-1 px-1">
|
||||
<table className="text-sm border-collapse min-w-full">
|
||||
<tbody>
|
||||
{step.tableRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className={rowIndex === 0 ? 'font-medium' : ''}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className="px-2 py-1 border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightStringsInText(text: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /"[^"]*"/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex, match.index)}</span>);
|
||||
}
|
||||
parts.push(
|
||||
<span key={`string-${match.index}`} className="font-medium text-orange-600 dark:text-orange-400">
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={`text-${lastIndex}`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? <>{parts}</> : text;
|
||||
}
|
||||
|
||||
// Click-based popover for step definitions (works on mobile and desktop)
|
||||
function StepDefinitionPopover({
|
||||
stepDef,
|
||||
children
|
||||
}: {
|
||||
stepDef: StepDefinitionInfo;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close on escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const dottedUnderlineStyle = {
|
||||
borderBottom: '1.3px dashed rgb(161 161 170)', // zinc-400
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline">
|
||||
<span
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="cursor-pointer"
|
||||
style={dottedUnderlineStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute left-0 top-full mt-1 z-50 shadow-xl rounded-lg"
|
||||
style={{ minWidth: '300px', maxWidth: 'min(90vw, 500px)' }}
|
||||
>
|
||||
<SourceCodePopup stepDef={stepDef} />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCodePopup({ stepDef }: { stepDef: StepDefinitionInfo }) {
|
||||
const lines = stepDef.sourceCode.split('\n');
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg overflow-hidden min-w-[300px]">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 bg-zinc-800 border-b border-zinc-700 flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-400 font-medium">
|
||||
{stepDef.file}:{stepDef.lineNumber}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
stepDef.keyword === 'Given' ? 'bg-blue-500/20 text-blue-400' :
|
||||
stepDef.keyword === 'When' ? 'bg-amber-500/20 text-amber-400' :
|
||||
'bg-green-500/20 text-green-400'
|
||||
}`}>
|
||||
{stepDef.keyword}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Code */}
|
||||
<div className="p-3 overflow-x-auto">
|
||||
<pre className="text-xs leading-relaxed">
|
||||
<code>
|
||||
{lines.map((codeLine, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="w-6 text-right pr-2 text-zinc-600 select-none text-[10px]">
|
||||
{stepDef.lineNumber + i}
|
||||
</span>
|
||||
<span className="text-zinc-300">
|
||||
{highlightTypeScript(codeLine)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightTypeScript(code: string): React.ReactNode {
|
||||
// Simple TypeScript syntax highlighting
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = code;
|
||||
let key = 0;
|
||||
|
||||
const patterns: Array<{ regex: RegExp; className: string }> = [
|
||||
// Keywords
|
||||
{ regex: /^(async|function|const|let|var|if|else|for|return|this|await|new|typeof|import|export|from)\b/, className: 'text-purple-400' },
|
||||
// Cucumber keywords
|
||||
{ regex: /^(Given|When|Then)\b/, className: 'text-amber-400 font-medium' },
|
||||
// Strings (single, double, backtick)
|
||||
{ regex: /^'(?:[^'\\]|\\.)*'/, className: 'text-green-400' },
|
||||
{ regex: /^"(?:[^"\\]|\\.)*"/, className: 'text-green-400' },
|
||||
{ regex: /^`(?:[^`\\]|\\.)*`/, className: 'text-green-400' },
|
||||
// Comments
|
||||
{ regex: /^\/\/.*$/, className: 'text-zinc-500 italic' },
|
||||
// Types after colon
|
||||
{ regex: /^:\s*[A-Z][a-zA-Z0-9]*/, className: 'text-cyan-400' },
|
||||
// Numbers
|
||||
{ regex: /^\d+/, className: 'text-orange-400' },
|
||||
// Booleans
|
||||
{ regex: /^(true|false|null|undefined)\b/, className: 'text-orange-400' },
|
||||
// Methods/functions
|
||||
{ regex: /^(\.[a-zA-Z_][a-zA-Z0-9_]*)\s*\(/, className: 'text-blue-300' },
|
||||
// Properties
|
||||
{ regex: /^(\.[a-zA-Z_][a-zA-Z0-9_]*)/, className: 'text-zinc-200' },
|
||||
// Arrows
|
||||
{ regex: /^=>/, className: 'text-purple-400' },
|
||||
// Brackets and operators
|
||||
{ regex: /^[{}()\[\];,]/, className: 'text-zinc-400' },
|
||||
];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
let matched = false;
|
||||
|
||||
for (const { regex, className } of patterns) {
|
||||
const match = remaining.match(regex);
|
||||
if (match) {
|
||||
parts.push(
|
||||
<span key={key++} className={className}>
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
remaining = remaining.slice(match[0].length);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
// No pattern matched, take one character
|
||||
parts.push(<span key={key++}>{remaining[0]}</span>);
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { parsedFeatures, getFeatureById } from '../../../shared/data/features';
|
||||
import { categoryLabels, categoryColors, priorityLabels, priorityColors, getStoryById, getScreenIdsWithStories, type StoryCategory } from '../../../shared/data';
|
||||
import { getTestStatus, getTestSummary } from '../../../shared/data/testResults';
|
||||
import { getScreen } from '../../../screens';
|
||||
import { FeatureView } from './FeatureView';
|
||||
import { FeatureFilter } from './FeatureFilter';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../../../shared/components/ui/card';
|
||||
import { Button } from '../../../shared/components/ui/button';
|
||||
import { ArrowLeft, FileText, Monitor, CheckCircle2, XCircle, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
import type { ParsedFeature } from '../../../shared/types/gherkin';
|
||||
import { ThemeToggle } from '../ThemeToggle';
|
||||
|
||||
interface SpecsPageProps {
|
||||
selectedFeatureId?: string;
|
||||
selectedStoryId?: string;
|
||||
onBack: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onSelectStory: (storyId: string) => void;
|
||||
}
|
||||
|
||||
export function SpecsPage({ selectedFeatureId, selectedStoryId, onBack, onSelectScreen, onSelectStory }: SpecsPageProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
|
||||
const [selectedPriorities, setSelectedPriorities] = useState<Set<number>>(new Set());
|
||||
const [selectedScreens, setSelectedScreens] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const featureRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Get screens that have linked stories for the filter
|
||||
const screensWithStories = useMemo(() => {
|
||||
const screenIds = getScreenIdsWithStories();
|
||||
return screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(({ screen }) => screen !== undefined);
|
||||
}, []);
|
||||
|
||||
// Scroll to selected story on mount
|
||||
useEffect(() => {
|
||||
if (selectedStoryId) {
|
||||
const element = featureRefs.current.get(selectedStoryId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [selectedStoryId]);
|
||||
|
||||
// Filter features - must be before any conditional returns to respect hooks rules
|
||||
const filteredFeatures = useMemo(() => {
|
||||
return parsedFeatures.filter(feature => {
|
||||
if (selectedCategories.size > 0 && !selectedCategories.has(feature.category)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedPriorities.size > 0 && !selectedPriorities.has(feature.priority)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedScreens.size > 0) {
|
||||
const linkedStory = getStoryById(feature.id);
|
||||
if (!linkedStory || !linkedStory.screenIds.some(id => selectedScreens.has(id))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return feature.name.toLowerCase().includes(query) ||
|
||||
feature.description.toLowerCase().includes(query);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectedCategories, selectedPriorities, selectedScreens, searchQuery]);
|
||||
|
||||
// Group by priority
|
||||
const featuresByPriority = [0, 1, 2, 3].map(priority => ({
|
||||
priority,
|
||||
features: filteredFeatures.filter(f => f.priority === priority),
|
||||
})).filter(({ features }) => features.length > 0);
|
||||
|
||||
// If a feature is selected, show detail view
|
||||
if (selectedFeatureId) {
|
||||
const feature = getFeatureById(selectedFeatureId);
|
||||
if (feature) {
|
||||
return (
|
||||
<FeatureView
|
||||
feature={feature}
|
||||
onBack={onBack}
|
||||
onSelectScreen={onSelectScreen}
|
||||
onSelectStory={onSelectStory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const testSummary = getTestSummary();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 sm:px-8 py-4 sm:py-6 bg-card">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Retour</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold">Specs BDD</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||
{filteredFeatures.length} / {parsedFeatures.length} fonctionnalités
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:hidden ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
{/* Test Results Summary */}
|
||||
{testSummary.totalScenarios > 0 && (
|
||||
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm flex-wrap">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4 text-green-500" />
|
||||
<span className="text-green-600 font-medium">{testSummary.passed}</span>
|
||||
</div>
|
||||
{testSummary.failed > 0 && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<XCircle className="w-3 h-3 sm:w-4 sm:h-4 text-red-500" />
|
||||
<span className="text-red-600 font-medium">{testSummary.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
{testSummary.skipped > 0 && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<AlertCircle className="w-3 h-3 sm:w-4 sm:h-4 text-yellow-500" />
|
||||
<span className="text-yellow-600 font-medium">{testSummary.skipped}</span>
|
||||
</div>
|
||||
)}
|
||||
<a href="/reports/cucumber" target="_blank" rel="noopener noreferrer" className="hidden sm:block">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Rapport
|
||||
</Button>
|
||||
</a>
|
||||
<div className="hidden sm:block">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testSummary.totalScenarios === 0 && <div className="hidden sm:block"><ThemeToggle /></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FeatureFilter
|
||||
selectedCategories={selectedCategories}
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
selectedPriorities={selectedPriorities}
|
||||
onPrioritiesChange={setSelectedPriorities}
|
||||
selectedScreens={selectedScreens}
|
||||
onScreensChange={setSelectedScreens}
|
||||
screensWithStories={screensWithStories}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Feature list */}
|
||||
<div className="px-4 sm:px-8 py-4 sm:py-6 space-y-6 sm:space-y-8">
|
||||
{featuresByPriority.map(({ priority, features }) => (
|
||||
<div key={priority}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span
|
||||
className="px-3 py-1 text-sm font-medium text-white rounded-md"
|
||||
style={{ backgroundColor: priorityColors[priority] }}
|
||||
>
|
||||
P{priority}
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Priorite {priorityLabels[priority]}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({features.length} fonctionnalites)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:gap-4">
|
||||
{features.map(feature => (
|
||||
<FeatureCard
|
||||
key={feature.id}
|
||||
ref={(el) => {
|
||||
if (el) featureRefs.current.set(feature.id, el);
|
||||
}}
|
||||
feature={feature}
|
||||
isSelected={feature.id === selectedStoryId}
|
||||
onClick={() => window.location.hash = `#/specs/${feature.id}`}
|
||||
onSelectScreen={onSelectScreen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{featuresByPriority.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Aucune fonctionnalite ne correspond aux filtres selectionnes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split user story description into separate lines
|
||||
function formatUserStory(description: string): string[] {
|
||||
// Split on user story keywords while keeping the keywords
|
||||
return description
|
||||
.split(/(?=En tant qu|Je peux|Je veux|Et |Afin d)/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: ParsedFeature;
|
||||
isSelected?: boolean;
|
||||
onClick: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
}
|
||||
|
||||
const FeatureCard = React.forwardRef<HTMLDivElement, FeatureCardProps>(
|
||||
function FeatureCard({ feature, isSelected, onClick, onSelectScreen }, ref) {
|
||||
const linkedStory = getStoryById(feature.id);
|
||||
const linkedScreens = linkedStory?.screenIds
|
||||
.map(id => ({ id, screen: getScreen(id) }))
|
||||
.filter(({ screen }) => screen !== undefined) || [];
|
||||
const testStatus = getTestStatus(feature.id);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!testStatus) return null;
|
||||
if (testStatus.failed > 0) {
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
}
|
||||
if (testStatus.skipped > 0) {
|
||||
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
|
||||
}
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!testStatus) return null;
|
||||
if (testStatus.failed > 0) {
|
||||
return <span className="text-red-600">{testStatus.passed}/{testStatus.totalScenarios}</span>;
|
||||
}
|
||||
if (testStatus.skipped > 0) {
|
||||
return <span className="text-yellow-600">{testStatus.passed}/{testStatus.totalScenarios}</span>;
|
||||
}
|
||||
return <span className="text-green-600">{testStatus.passed}/{testStatus.totalScenarios}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={`cursor-pointer hover:border-primary hover:shadow-md transition-all ${
|
||||
isSelected ? 'border-2 border-blue-500 bg-blue-50 dark:bg-blue-950/20' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs font-medium text-white rounded"
|
||||
style={{ backgroundColor: categoryColors[feature.category as StoryCategory] }}
|
||||
>
|
||||
{categoryLabels[feature.category as StoryCategory]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{feature.id.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{testStatus && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{getStatusIcon()}
|
||||
{getStatusText()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base leading-tight line-clamp-2">
|
||||
{feature.name.replace(/^US-\d+\s*/, '')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{feature.description && (
|
||||
<div className="text-sm text-muted-foreground mb-3 space-y-1">
|
||||
{formatUserStory(feature.description).map((line, i) => (
|
||||
<div key={i} className="leading-snug">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
{feature.scenarios.length} scenarios
|
||||
</span>
|
||||
{linkedScreens.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Monitor className="w-3 h-3" />
|
||||
{linkedScreens.length} ecrans
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Screen buttons */}
|
||||
{linkedScreens.length > 0 ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{linkedScreens.map(({ id, screen }) => (
|
||||
<Button
|
||||
key={id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectScreen(id);
|
||||
}}
|
||||
>
|
||||
{screen!.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Pas encore de mockup
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export { SpecsPage } from './SpecsPage';
|
||||
export { FeatureView } from './FeatureView';
|
||||
export { FeatureFilter } from './FeatureFilter';
|
||||
export { GherkinHighlighter } from './GherkinHighlighter';
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* This file is the entry point for the React app, it sets up the root
|
||||
* element and renders the App component to the DOM.
|
||||
*
|
||||
* It is included in `src/index.html`.
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type Route =
|
||||
| { page: 'gallery' }
|
||||
| { page: 'demo'; screenId: string }
|
||||
| { page: 'specs'; featureId?: string; storyId?: string };
|
||||
|
||||
interface RouterContextValue {
|
||||
route: Route;
|
||||
navigate: (route: Route) => void;
|
||||
goBack: () => void;
|
||||
}
|
||||
|
||||
const RouterContext = createContext<RouterContextValue | null>(null);
|
||||
|
||||
function parseHash(hash: string): Route {
|
||||
const path = hash.replace(/^#\/?/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') {
|
||||
return { page: 'gallery' };
|
||||
}
|
||||
|
||||
// Redirect /stories to /specs (backward compatibility)
|
||||
if (path === 'stories') {
|
||||
return { page: 'specs' };
|
||||
}
|
||||
|
||||
// Redirect /stories/{id} to /specs with storyId (backward compatibility)
|
||||
if (path.startsWith('stories/')) {
|
||||
const storyId = path.replace('stories/', '');
|
||||
if (storyId) {
|
||||
return { page: 'specs', storyId };
|
||||
}
|
||||
}
|
||||
|
||||
if (path.startsWith('demo/')) {
|
||||
const screenId = path.replace('demo/', '');
|
||||
if (screenId) {
|
||||
return { page: 'demo', screenId };
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'specs') {
|
||||
return { page: 'specs' };
|
||||
}
|
||||
|
||||
if (path.startsWith('specs/')) {
|
||||
const featureId = path.replace('specs/', '');
|
||||
if (featureId) {
|
||||
return { page: 'specs', featureId };
|
||||
}
|
||||
}
|
||||
|
||||
return { page: 'gallery' };
|
||||
}
|
||||
|
||||
function routeToHash(route: Route): string {
|
||||
switch (route.page) {
|
||||
case 'gallery':
|
||||
return '#/';
|
||||
case 'demo':
|
||||
return `#/demo/${route.screenId}`;
|
||||
case 'specs':
|
||||
if (route.featureId) return `#/specs/${route.featureId}`;
|
||||
if (route.storyId) return `#/specs/${route.storyId}`;
|
||||
return '#/specs';
|
||||
}
|
||||
}
|
||||
|
||||
export function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||
const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
setRoute(parseHash(window.location.hash));
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((newRoute: Route) => {
|
||||
window.location.hash = routeToHash(newRoute);
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
window.history.back();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RouterContext.Provider value={{ route, navigate, goBack }}>
|
||||
{children}
|
||||
</RouterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRouter() {
|
||||
const context = useContext(RouterContext);
|
||||
if (!context) {
|
||||
throw new Error('useRouter must be used within a RouterProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useNavigate() {
|
||||
const { navigate } = useRouter();
|
||||
return navigate;
|
||||
}
|
||||
|
||||
export function useGoBack() {
|
||||
const { goBack } = useRouter();
|
||||
return goBack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL for a specific story (now redirects to specs)
|
||||
*/
|
||||
export function getStoryUrl(storyId: string): string {
|
||||
return `#/specs/${storyId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL for a specific feature spec
|
||||
*/
|
||||
export function getSpecUrl(featureId: string): string {
|
||||
return `#/specs/${featureId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user