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:
Sylvain Duchesne
2026-03-11 12:19:45 +01:00
parent c9bc957d2a
commit 901fd659df
128 changed files with 5738 additions and 2885 deletions
+57
View File
@@ -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;
+426
View File
@@ -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>
);
}
+199
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+577
View File
@@ -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>
);
});
+285
View File
@@ -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>
);
}
+117
View File
@@ -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}</>;
}
+333
View File
@@ -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>
);
});
+4
View File
@@ -0,0 +1,4 @@
export { SpecsPage } from './SpecsPage';
export { FeatureView } from './FeatureView';
export { FeatureFilter } from './FeatureFilter';
export { GherkinHighlighter } from './GherkinHighlighter';
+26
View File
@@ -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);
}
+127
View File
@@ -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}`;
}