Merge User Stories page into Specs page
- SpecsPage: Add screen filter, scroll-to-story, selection highlight - FeatureFilter: Add screen filter chips for both mobile and desktop - Router: Redirect /stories/* routes to /specs/* for backward compatibility - App: Remove UserStoriesPage routing, simplify navigation - Gallery: Remove User Stories button, keep only Specs BDD - Button: Add cursor-pointer to base styles Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+3
-14
@@ -3,7 +3,6 @@ import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { Gallery } from './components/Gallery';
|
||||
import { DemoMode } from './components/DemoMode';
|
||||
import { UserStoriesPage } from './components/UserStoriesPage';
|
||||
import { SpecsPage } from './components/specs';
|
||||
|
||||
function AppContent() {
|
||||
@@ -14,17 +13,7 @@ function AppContent() {
|
||||
<DemoMode
|
||||
initialScreenId={route.screenId}
|
||||
onBack={goBack}
|
||||
onNavigateToStory={(storyId) => navigate({ page: 'stories', storyId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (route.page === 'stories') {
|
||||
return (
|
||||
<UserStoriesPage
|
||||
selectedStoryId={route.storyId}
|
||||
onBack={goBack}
|
||||
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
|
||||
onNavigateToStory={(storyId) => navigate({ page: 'specs', storyId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -33,9 +22,10 @@ function AppContent() {
|
||||
return (
|
||||
<SpecsPage
|
||||
selectedFeatureId={route.featureId}
|
||||
selectedStoryId={route.storyId}
|
||||
onBack={goBack}
|
||||
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
|
||||
onSelectStory={(storyId) => navigate({ page: 'stories', storyId })}
|
||||
onSelectStory={(storyId) => navigate({ page: 'specs', storyId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +33,6 @@ function AppContent() {
|
||||
return (
|
||||
<Gallery
|
||||
onSelectScreen={(screenId) => navigate({ page: 'demo', screenId })}
|
||||
onShowStories={() => navigate({ page: 'stories' })}
|
||||
onShowSpecs={() => navigate({ page: 'specs' })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,15 +15,14 @@ function useIsMobile(breakpoint = 768) {
|
||||
|
||||
interface GalleryProps {
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onShowStories: () => void;
|
||||
onShowSpecs?: () => void;
|
||||
onShowSpecs: () => void;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.32;
|
||||
const MAX_SCALE = 0.75;
|
||||
const DEFAULT_SCALE = 0.5;
|
||||
|
||||
export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryProps) {
|
||||
export function Gallery({ onSelectScreen, onShowSpecs }: GalleryProps) {
|
||||
const [scale, setScale] = useState(DEFAULT_SCALE);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -67,42 +66,23 @@ export function Gallery({ onSelectScreen, onShowStories, onShowSpecs }: GalleryP
|
||||
gap: isMobile ? 8 : 24,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* User Stories button */}
|
||||
{/* Specs BDD button */}
|
||||
<button
|
||||
onClick={onShowStories}
|
||||
onClick={onShowSpecs}
|
||||
style={{
|
||||
background: 'none',
|
||||
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',
|
||||
color: 'var(--tool-text)',
|
||||
}}
|
||||
>
|
||||
User Stories
|
||||
Specs BDD
|
||||
</button>
|
||||
|
||||
{/* Specs BDD button */}
|
||||
{onShowSpecs && (
|
||||
<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={{
|
||||
|
||||
@@ -16,11 +16,19 @@ function useIsMobile(breakpoint = 640) {
|
||||
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;
|
||||
}
|
||||
@@ -30,6 +38,9 @@ export function FeatureFilter({
|
||||
onCategoriesChange,
|
||||
selectedPriorities,
|
||||
onPrioritiesChange,
|
||||
selectedScreens,
|
||||
onScreensChange,
|
||||
screensWithStories,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: FeatureFilterProps) {
|
||||
@@ -56,14 +67,25 @@ export function FeatureFilter({
|
||||
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 || searchQuery;
|
||||
const activeFilterCount = selectedCategories.size + selectedPriorities.size + (searchQuery ? 1 : 0);
|
||||
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) {
|
||||
@@ -141,6 +163,26 @@ export function FeatureFilter({
|
||||
</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">
|
||||
@@ -211,6 +253,25 @@ export function FeatureFilter({
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { parsedFeatures, getFeatureById } from '../../data/features';
|
||||
import { categoryLabels, categoryColors, priorityLabels, priorityColors, getStoryById, type StoryCategory } from '../../data';
|
||||
import { categoryLabels, categoryColors, priorityLabels, priorityColors, getStoryById, getScreenIdsWithStories, type StoryCategory } from '../../data';
|
||||
import { getTestStatus, getTestSummary } from '../../data/testResults';
|
||||
import { getScreen } from '../../screens';
|
||||
import { FeatureView } from './FeatureView';
|
||||
import { FeatureFilter } from './FeatureFilter';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../ui/card';
|
||||
@@ -12,15 +13,36 @@ import { ThemeToggle } from '../ThemeToggle';
|
||||
|
||||
interface SpecsPageProps {
|
||||
selectedFeatureId?: string;
|
||||
selectedStoryId?: string;
|
||||
onBack: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
onSelectStory: (storyId: string) => void;
|
||||
}
|
||||
|
||||
export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectStory }: SpecsPageProps) {
|
||||
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(() => {
|
||||
@@ -31,6 +53,12 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
|
||||
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) ||
|
||||
@@ -38,7 +66,7 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectedCategories, selectedPriorities, searchQuery]);
|
||||
}, [selectedCategories, selectedPriorities, selectedScreens, searchQuery]);
|
||||
|
||||
// Group by priority
|
||||
const featuresByPriority = [0, 1, 2, 3].map(priority => ({
|
||||
@@ -123,6 +151,9 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
selectedPriorities={selectedPriorities}
|
||||
onPrioritiesChange={setSelectedPriorities}
|
||||
selectedScreens={selectedScreens}
|
||||
onScreensChange={setSelectedScreens}
|
||||
screensWithStories={screensWithStories}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
@@ -150,8 +181,13 @@ export function SpecsPage({ selectedFeatureId, onBack, onSelectScreen, onSelectS
|
||||
{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>
|
||||
@@ -179,11 +215,17 @@ function formatUserStory(description: string): string[] {
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: ParsedFeature;
|
||||
isSelected?: boolean;
|
||||
onClick: () => void;
|
||||
onSelectScreen: (screenId: string) => void;
|
||||
}
|
||||
|
||||
function FeatureCard({ feature, onClick }: FeatureCardProps) {
|
||||
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 = () => {
|
||||
@@ -210,7 +252,10 @@ function FeatureCard({ feature, onClick }: FeatureCardProps) {
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary hover:shadow-md transition-all"
|
||||
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">
|
||||
@@ -247,19 +292,42 @@ function FeatureCard({ feature, onClick }: FeatureCardProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<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>
|
||||
{linkedStory && linkedStory.screenIds.length > 0 && (
|
||||
{linkedScreens.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Monitor className="w-3 h-3" />
|
||||
{linkedStory.screenIds.length} ecrans
|
||||
{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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
+10
-9
@@ -2,9 +2,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
|
||||
|
||||
type Route =
|
||||
| { page: 'gallery' }
|
||||
| { page: 'stories'; storyId?: string }
|
||||
| { page: 'demo'; screenId: string }
|
||||
| { page: 'specs'; featureId?: string };
|
||||
| { page: 'specs'; featureId?: string; storyId?: string };
|
||||
|
||||
interface RouterContextValue {
|
||||
route: Route;
|
||||
@@ -21,14 +20,16 @@ function parseHash(hash: string): Route {
|
||||
return { page: 'gallery' };
|
||||
}
|
||||
|
||||
// Redirect /stories to /specs (backward compatibility)
|
||||
if (path === 'stories') {
|
||||
return { page: '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: 'stories', storyId };
|
||||
return { page: 'specs', storyId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +58,12 @@ function routeToHash(route: Route): string {
|
||||
switch (route.page) {
|
||||
case 'gallery':
|
||||
return '#/';
|
||||
case 'stories':
|
||||
return route.storyId ? `#/stories/${route.storyId}` : '#/stories';
|
||||
case 'demo':
|
||||
return `#/demo/${route.screenId}`;
|
||||
case 'specs':
|
||||
return route.featureId ? `#/specs/${route.featureId}` : '#/specs';
|
||||
if (route.featureId) return `#/specs/${route.featureId}`;
|
||||
if (route.storyId) return `#/specs/${route.storyId}`;
|
||||
return '#/specs';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +113,10 @@ export function useGoBack() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL for a specific story
|
||||
* Generate a URL for a specific story (now redirects to specs)
|
||||
*/
|
||||
export function getStoryUrl(storyId: string): string {
|
||||
return `#/stories/${storyId}`;
|
||||
return `#/specs/${storyId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user