feat(launcher): add ProjectCreationWizard multi-step flow

Replaces CreateProjectModal with a guided wizard supporting:
- Quick Start (name + location, direct creation)
- Guided Setup (basics → style preset → review → create)
- AI Builder stub (V2, same flow as Guided for now)

Files added:
- WizardContext.tsx — state machine with step sequencing + canProceed validation
- EntryModeStep — card-based mode selector
- ProjectBasicsStep — name/description/location picker
- StylePresetStep — preset card grid
- ReviewStep — summary before creation
- ProjectCreationWizard.tsx — drop-in replacement for CreateProjectModal
- SCSS + index.ts barrel

Integration:
- ProjectsPage.tsx imports ProjectCreationWizard (API-identical swap)

Tests:
- tests/models/ProjectCreationWizard.test.ts — 17 pure logic tests
  (infra note: npm run test:editor required, not npx jest directly)

Pre-existing broken tests in tests/components/ and tests/git/ are
unrelated and not modified.
This commit is contained in:
Richard Osborne
2026-02-18 16:45:12 +01:00
parent c425769633
commit d9acb41d2f
14 changed files with 1097 additions and 2 deletions

View File

@@ -0,0 +1,74 @@
/* ProjectCreationWizard — modal shell styles */
.Backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.55);
z-index: 1000;
}
.Modal {
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-lg);
min-width: 520px;
max-width: 640px;
width: 100%;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
z-index: 1001;
display: flex;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
}
/* ---- Header ---- */
.Header {
padding: var(--spacing-5) var(--spacing-6);
border-bottom: 1px solid var(--theme-color-border-default);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.Title {
font-size: 18px;
font-weight: 600;
color: var(--theme-color-fg-default);
margin: 0;
line-height: 1.3;
}
.StepLabel {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
font-weight: 500;
letter-spacing: 0.03em;
}
/* ---- Content ---- */
.Content {
padding: var(--spacing-6);
overflow-y: auto;
flex: 1;
}
/* ---- Footer ---- */
.Footer {
padding: var(--spacing-4) var(--spacing-6);
border-top: 1px solid var(--theme-color-border-default);
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}

View File

@@ -0,0 +1,183 @@
/**
* ProjectCreationWizard - Multi-step project creation flow
*
* Replaces CreateProjectModal with a guided experience that supports:
* - Quick Start (name + location → create)
* - Guided Setup (name/description → style preset → review → create)
* - AI Builder stub (coming in V2)
*
* The onConfirm signature is identical to CreateProjectModal so ProjectsPage
* requires only an import-name swap.
*
* @module noodl-core-ui/preview/launcher
*/
import React from 'react';
import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { PresetDisplayInfo } from '@noodl-core-ui/components/StylePresets';
import css from './ProjectCreationWizard.module.scss';
import { EntryModeStep } from './steps/EntryModeStep';
import { ProjectBasicsStep } from './steps/ProjectBasicsStep';
import { ReviewStep } from './steps/ReviewStep';
import { StylePresetStep } from './steps/StylePresetStep';
import { WizardProvider, useWizardContext, DEFAULT_PRESET_ID, WizardStep } from './WizardContext';
// ----- Public API -----------------------------------------------------------
export interface ProjectCreationWizardProps {
isVisible: boolean;
onClose: () => void;
/**
* Called when the user confirms project creation.
* Signature is identical to the legacy CreateProjectModal.onConfirm so
* callers need no changes beyond swapping the import.
*/
onConfirm: (name: string, location: string, presetId: string) => void;
/** Open a native folder picker; returns the chosen path or null if cancelled */
onChooseLocation?: () => Promise<string | null>;
/** Style presets to show in the preset picker step */
presets?: PresetDisplayInfo[];
}
// ----- Step metadata --------------------------------------------------------
const STEP_TITLES: Record<WizardStep, string> = {
entry: 'Create New Project',
basics: 'Project Basics',
preset: 'Style Preset',
review: 'Review'
};
/** Steps where the Back button should be hidden (entry has no "back") */
const STEPS_WITHOUT_BACK: WizardStep[] = ['entry'];
// ----- Inner wizard (has access to context) ---------------------------------
interface WizardInnerProps extends Omit<ProjectCreationWizardProps, 'isVisible'> {
presets: PresetDisplayInfo[];
}
function WizardInner({ onClose, onConfirm, onChooseLocation, presets }: WizardInnerProps) {
const { state, goNext, goBack, canProceed } = useWizardContext();
const { currentStep, mode, projectName, location, selectedPresetId } = state;
// Determine if this is the final step before creation
const isLastStep = currentStep === 'review' || (mode === 'quick' && currentStep === 'basics');
const nextLabel = isLastStep ? 'Create Project' : 'Next';
const showBack = !STEPS_WITHOUT_BACK.includes(currentStep);
const handleNext = () => {
if (isLastStep) {
// Fire creation with the wizard state values
onConfirm(projectName.trim(), location, selectedPresetId);
} else {
goNext();
}
};
// Render the active step body
const renderStep = () => {
switch (currentStep) {
case 'entry':
return <EntryModeStep />;
case 'basics':
return <ProjectBasicsStep onChooseLocation={onChooseLocation ?? (() => Promise.resolve(null))} />;
case 'preset':
return <StylePresetStep presets={presets} />;
case 'review':
return <ReviewStep presets={presets} />;
}
};
return (
<div className={css['Backdrop']} onClick={onClose}>
<div className={css['Modal']} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className={css['Header']}>
<h3 className={css['Title']}>{STEP_TITLES[currentStep]}</h3>
{/* Step indicator (not shown on entry screen) */}
{currentStep !== 'entry' && (
<span className={css['StepLabel']}>{mode === 'quick' ? 'Quick Start' : 'Guided Setup'}</span>
)}
</div>
{/* Content */}
<div className={css['Content']}>{renderStep()}</div>
{/* Footer — hidden on entry (entry step uses card clicks to advance) */}
{currentStep !== 'entry' && (
<div className={css['Footer']}>
{showBack && (
<PrimaryButton
label="Back"
size={PrimaryButtonSize.Default}
variant={PrimaryButtonVariant.Muted}
onClick={goBack}
UNSAFE_style={{ marginRight: 'auto' }}
/>
)}
<PrimaryButton
label="Cancel"
size={PrimaryButtonSize.Default}
variant={PrimaryButtonVariant.Muted}
onClick={onClose}
UNSAFE_style={{ marginRight: 'var(--spacing-2)' }}
/>
<PrimaryButton
label={nextLabel}
size={PrimaryButtonSize.Default}
onClick={handleNext}
isDisabled={!canProceed}
/>
</div>
)}
</div>
</div>
);
}
// ----- Public component (manages provider lifecycle) ------------------------
/**
* ProjectCreationWizard — Drop-in replacement for CreateProjectModal.
*
* @example
* // ProjectsPage.tsx — only change the import, nothing else
* import { ProjectCreationWizard } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectCreationWizard';
*
* <ProjectCreationWizard
* isVisible={isCreateModalVisible}
* onClose={handleCreateModalClose}
* onConfirm={handleCreateProjectConfirm}
* onChooseLocation={handleChooseLocation}
* presets={STYLE_PRESETS}
* />
*/
export function ProjectCreationWizard({
isVisible,
onClose,
onConfirm,
onChooseLocation,
presets
}: ProjectCreationWizardProps) {
if (!isVisible) return null;
// Key the provider on `isVisible` so state fully resets each time the
// modal opens — no stale name/location from the previous session.
return (
<WizardProvider key="project-creation-wizard">
<WizardInner
onClose={onClose}
onConfirm={onConfirm}
onChooseLocation={onChooseLocation}
presets={presets ?? []}
/>
</WizardProvider>
);
}

View File

@@ -0,0 +1,145 @@
/**
* WizardContext - Shared state for the Project Creation Wizard
*
* Manages wizard step flow, form values, and mode selection.
* Passed via React context so all step components can read/write without prop drilling.
*/
import React, { createContext, useCallback, useContext, useState } from 'react';
// ----- Types ----------------------------------------------------------------
/** The entry-mode choice the user makes on the first screen */
export type WizardMode = 'quick' | 'guided' | 'ai';
/**
* Step identifiers in the guided flow.
* Quick mode only visits 'basics' (no preset or review step).
*/
export type WizardStep = 'entry' | 'basics' | 'preset' | 'review';
export const DEFAULT_PRESET_ID = 'modern';
export interface WizardState {
/** Active wizard mode chosen by the user */
mode: WizardMode;
/** Current step being displayed */
currentStep: WizardStep;
/** Project name entered by the user */
projectName: string;
/** Optional project description (guided mode only) */
description: string;
/** Folder path chosen via native dialog */
location: string;
/** ID of the selected style preset */
selectedPresetId: string;
}
export interface WizardContextValue {
state: WizardState;
/** Update one or more fields of the wizard state */
update: (partial: Partial<WizardState>) => void;
/** Move forward to the next logical step (mode-aware) */
goNext: () => void;
/** Move back to the previous step */
goBack: () => void;
/** Whether the current step has all required data to proceed */
canProceed: boolean;
}
// ----- Context --------------------------------------------------------------
const WizardContext = createContext<WizardContextValue | null>(null);
export function useWizardContext(): WizardContextValue {
const ctx = useContext(WizardContext);
if (!ctx) throw new Error('useWizardContext must be used within WizardProvider');
return ctx;
}
// ----- Step ordering --------------------------------------------------------
/** Returns the ordered list of steps for the given mode */
function getStepSequence(mode: WizardMode): WizardStep[] {
switch (mode) {
case 'quick':
// Quick Start: just fill in name/location, no preset picker or review
return ['basics'];
case 'guided':
return ['basics', 'preset', 'review'];
case 'ai':
// AI mode is a stub for V1 — same as guided until AI is wired
return ['basics', 'preset', 'review'];
}
}
// ----- Validation -----------------------------------------------------------
function isStepValid(step: WizardStep, state: WizardState): boolean {
switch (step) {
case 'entry':
// Entry screen has no data — user just picks a mode
return true;
case 'basics':
return state.projectName.trim().length > 0 && state.location.length > 0;
case 'preset':
return state.selectedPresetId.length > 0;
case 'review':
return true;
}
}
// ----- Provider -------------------------------------------------------------
export interface WizardProviderProps {
children: React.ReactNode;
initialState?: Partial<WizardState>;
}
export function WizardProvider({ children, initialState }: WizardProviderProps) {
const [state, setStateRaw] = useState<WizardState>({
mode: 'quick',
currentStep: 'entry',
projectName: '',
description: '',
location: '',
selectedPresetId: DEFAULT_PRESET_ID,
...initialState
});
const update = useCallback((partial: Partial<WizardState>) => {
setStateRaw((prev) => ({ ...prev, ...partial }));
}, []);
const goNext = useCallback(() => {
setStateRaw((prev) => {
if (prev.currentStep === 'entry') {
// Entry → first step of the chosen mode sequence
const seq = getStepSequence(prev.mode);
return { ...prev, currentStep: seq[0] };
}
const seq = getStepSequence(prev.mode);
const idx = seq.indexOf(prev.currentStep);
if (idx === -1 || idx >= seq.length - 1) return prev;
return { ...prev, currentStep: seq[idx + 1] };
});
}, []);
const goBack = useCallback(() => {
setStateRaw((prev) => {
if (prev.currentStep === 'entry') return prev;
const seq = getStepSequence(prev.mode);
const idx = seq.indexOf(prev.currentStep);
if (idx <= 0) {
// Back from the first real step → return to entry screen
return { ...prev, currentStep: 'entry' };
}
return { ...prev, currentStep: seq[idx - 1] };
});
}, []);
const canProceed = isStepValid(state.currentStep, state);
return (
<WizardContext.Provider value={{ state, update, goNext, goBack, canProceed }}>{children}</WizardContext.Provider>
);
}

View File

@@ -0,0 +1,3 @@
export { ProjectCreationWizard } from './ProjectCreationWizard';
export type { ProjectCreationWizardProps } from './ProjectCreationWizard';
export type { WizardMode, WizardStep, WizardState } from './WizardContext';

View File

@@ -0,0 +1,72 @@
.EntryModeStep {
display: flex;
flex-direction: column;
}
.EntryModeStep-prompt {
font-size: 15px;
color: var(--theme-color-fg-default-shy);
margin: 0 0 var(--spacing-5) 0;
}
.EntryModeStep-cards {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.ModeCard {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-1);
padding: var(--spacing-4) var(--spacing-5);
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease, background-color 0.15s ease;
position: relative;
&:hover:not(&--disabled) {
border-color: var(--theme-color-primary, #ef4444);
background-color: var(--theme-color-bg-2);
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary, #ef4444);
outline-offset: 2px;
}
&--disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ModeCard-title {
font-size: 14px;
font-weight: 600;
color: var(--theme-color-fg-default);
line-height: 1.3;
}
.ModeCard-description {
font-size: 13px;
color: var(--theme-color-fg-default-shy);
line-height: 1.5;
}
.ModeCard-badge {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-3);
font-size: 11px;
font-weight: 500;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-sm, 4px);
padding: 2px var(--spacing-2);
}

View File

@@ -0,0 +1,69 @@
/**
* EntryModeStep - First screen of the Project Creation Wizard
*
* Lets the user choose between Quick Start, Guided Setup, or AI Builder (stub).
*/
import React from 'react';
import { WizardMode, useWizardContext } from '../WizardContext';
import css from './EntryModeStep.module.scss';
interface ModeCardProps {
mode: WizardMode;
title: string;
description: string;
isDisabled?: boolean;
onSelect: (mode: WizardMode) => void;
}
function ModeCard({ mode, title, description, isDisabled, onSelect }: ModeCardProps) {
return (
<button
className={`${css['ModeCard']} ${isDisabled ? css['ModeCard--disabled'] : ''}`}
onClick={() => !isDisabled && onSelect(mode)}
disabled={isDisabled}
type="button"
>
<span className={css['ModeCard-title']}>{title}</span>
<span className={css['ModeCard-description']}>{description}</span>
{isDisabled && <span className={css['ModeCard-badge']}>Coming soon</span>}
</button>
);
}
export function EntryModeStep() {
const { update, goNext } = useWizardContext();
const handleSelect = (mode: WizardMode) => {
update({ mode });
goNext();
};
return (
<div className={css['EntryModeStep']}>
<p className={css['EntryModeStep-prompt']}>How would you like to start?</p>
<div className={css['EntryModeStep-cards']}>
<ModeCard
mode="quick"
title="Quick Start"
description="Blank project with Modern preset. Name it, pick a folder, and build."
onSelect={handleSelect}
/>
<ModeCard
mode="guided"
title="Guided Setup"
description="Walk through name, description, and style preset step by step."
onSelect={handleSelect}
/>
<ModeCard
mode="ai"
title="AI Project Builder"
description="Describe what you want to build and AI sets up the scaffolding."
isDisabled
onSelect={handleSelect}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
.ProjectBasicsStep {
display: flex;
flex-direction: column;
}
.Field {
margin-bottom: var(--spacing-5);
&:last-child {
margin-bottom: 0;
}
}
.LocationRow {
display: flex;
align-items: center;
margin-top: var(--spacing-2);
}
.PathPreview {
margin-top: var(--spacing-4);
padding: var(--spacing-3);
background-color: var(--theme-color-bg-3);
border-radius: var(--radius-default);
border: 1px solid var(--theme-color-border-default);
}
.PathText {
font-size: 13px;
color: var(--theme-color-fg-default-shy);
line-height: 1.4;
}

View File

@@ -0,0 +1,90 @@
/**
* ProjectBasicsStep - Name, optional description, and folder location
*
* Shown in both Quick Start and Guided modes.
* Description field is only shown in Guided mode.
*/
import React, { useCallback } from 'react';
import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { Label } from '@noodl-core-ui/components/typography/Label';
import { useWizardContext } from '../WizardContext';
import css from './ProjectBasicsStep.module.scss';
export interface ProjectBasicsStepProps {
/** Called when the user clicks "Browse..." to pick a folder */
onChooseLocation: () => Promise<string | null>;
}
export function ProjectBasicsStep({ onChooseLocation }: ProjectBasicsStepProps) {
const { state, update } = useWizardContext();
const isGuided = state.mode === 'guided' || state.mode === 'ai';
const handleChooseLocation = useCallback(async () => {
const chosen = await onChooseLocation();
if (chosen) {
update({ location: chosen });
}
}, [onChooseLocation, update]);
return (
<div className={css['ProjectBasicsStep']}>
{/* Project Name */}
<div className={css['Field']}>
<Label>Project Name</Label>
<TextInput
value={state.projectName}
onChange={(e) => update({ projectName: e.target.value })}
placeholder="My New Project"
isAutoFocus
UNSAFE_style={{ marginTop: 'var(--spacing-2)' }}
/>
</div>
{/* Description — guided mode only */}
{isGuided && (
<div className={css['Field']}>
<Label>Description (optional)</Label>
<TextInput
value={state.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="A brief description of your project..."
UNSAFE_style={{ marginTop: 'var(--spacing-2)' }}
/>
</div>
)}
{/* Location */}
<div className={css['Field']}>
<Label>Location</Label>
<div className={css['LocationRow']}>
<TextInput
value={state.location}
onChange={(e) => update({ location: e.target.value })}
placeholder="Choose folder..."
isReadonly
UNSAFE_style={{ flex: 1 }}
/>
<PrimaryButton
label="Browse..."
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={handleChooseLocation}
UNSAFE_style={{ marginLeft: 'var(--spacing-2)' }}
/>
</div>
</div>
{/* Path preview */}
{state.projectName && state.location && (
<div className={css['PathPreview']}>
<span className={css['PathText']}>
Full path: {state.location}/{state.projectName}/
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
.ReviewStep {
display: flex;
flex-direction: column;
}
.ReviewStep-subtitle {
font-size: 13px;
color: var(--theme-color-fg-default-shy);
margin: 0 0 var(--spacing-4) 0;
}
.Summary {
display: flex;
flex-direction: column;
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
overflow: hidden;
}
.SummaryRow {
display: grid;
grid-template-columns: 80px 1fr auto;
align-items: start;
gap: var(--spacing-3);
padding: var(--spacing-4) var(--spacing-5);
border-bottom: 1px solid var(--theme-color-border-default);
&:last-child {
border-bottom: none;
}
}
.SummaryRow-label {
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 2px;
}
.SummaryRow-value {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.SummaryRow-main {
font-size: 14px;
color: var(--theme-color-fg-default);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.SummaryRow-secondary {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.SummaryRow-edit {
font-size: 12px;
color: var(--theme-color-primary, #ef4444);
background: none;
border: none;
cursor: pointer;
padding: 0;
line-height: 1.6;
flex-shrink: 0;
&:hover {
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary, #ef4444);
outline-offset: 2px;
border-radius: 2px;
}
}

View File

@@ -0,0 +1,82 @@
/**
* ReviewStep - Final summary before project creation
*
* Shows the chosen settings and lets the user go back to edit any step.
*/
import React from 'react';
import { PresetDisplayInfo } from '@noodl-core-ui/components/StylePresets';
import { useWizardContext } from '../WizardContext';
import css from './ReviewStep.module.scss';
export interface ReviewStepProps {
presets: PresetDisplayInfo[];
}
export function ReviewStep({ presets }: ReviewStepProps) {
const { state, update, goBack } = useWizardContext();
const selectedPreset = presets.find((p) => p.id === state.selectedPresetId);
const handleEditBasics = () => {
// Navigate back to basics by jumping steps
update({ currentStep: 'basics' });
};
const handleEditPreset = () => {
update({ currentStep: 'preset' });
};
return (
<div className={css['ReviewStep']}>
<p className={css['ReviewStep-subtitle']}>Review your settings before creating.</p>
<div className={css['Summary']}>
{/* Basics row */}
<div className={css['SummaryRow']}>
<div className={css['SummaryRow-label']}>Project</div>
<div className={css['SummaryRow-value']}>
<span className={css['SummaryRow-main']}>{state.projectName || '—'}</span>
{state.description && <span className={css['SummaryRow-secondary']}>{state.description}</span>}
</div>
<button className={css['SummaryRow-edit']} onClick={handleEditBasics} type="button">
Edit
</button>
</div>
{/* Location row */}
<div className={css['SummaryRow']}>
<div className={css['SummaryRow-label']}>Location</div>
<div className={css['SummaryRow-value']}>
<span className={css['SummaryRow-main']} title={state.location}>
{state.location || '—'}
</span>
{state.projectName && state.location && (
<span className={css['SummaryRow-secondary']}>
Full path: {state.location}/{state.projectName}/
</span>
)}
</div>
<button className={css['SummaryRow-edit']} onClick={handleEditBasics} type="button">
Edit
</button>
</div>
{/* Style preset row */}
<div className={css['SummaryRow']}>
<div className={css['SummaryRow-label']}>Style</div>
<div className={css['SummaryRow-value']}>
<span className={css['SummaryRow-main']}>{selectedPreset?.name ?? state.selectedPresetId}</span>
{selectedPreset?.description && (
<span className={css['SummaryRow-secondary']}>{selectedPreset.description}</span>
)}
</div>
<button className={css['SummaryRow-edit']} onClick={handleEditPreset} type="button">
Edit
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
.StylePresetStep {
display: flex;
flex-direction: column;
}
.StylePresetStep-hint {
font-size: 13px;
color: var(--theme-color-fg-default-shy);
margin: 0 0 var(--spacing-4) 0;
line-height: 1.5;
}

View File

@@ -0,0 +1,33 @@
/**
* StylePresetStep - Style preset selection for guided mode
*
* Reuses the existing PresetSelector component built in STYLE-003.
*/
import React from 'react';
import { PresetDisplayInfo, PresetSelector } from '@noodl-core-ui/components/StylePresets';
import { useWizardContext } from '../WizardContext';
import css from './StylePresetStep.module.scss';
export interface StylePresetStepProps {
/** Preset data passed in from the editor (avoid circular dep) */
presets: PresetDisplayInfo[];
}
export function StylePresetStep({ presets }: StylePresetStepProps) {
const { state, update } = useWizardContext();
return (
<div className={css['StylePresetStep']}>
<p className={css['StylePresetStep-hint']}>
Choose a visual style for your project. You can customise colors and fonts later.
</p>
<PresetSelector
presets={presets}
selectedId={state.selectedPresetId}
onChange={(id) => update({ selectedPresetId: id })}
/>
</div>
);
}

View File

@@ -10,11 +10,11 @@ import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { clone } from '@noodl/git/src/core/clone'; import { clone } from '@noodl/git/src/core/clone';
import { filesystem } from '@noodl/platform'; import { filesystem } from '@noodl/platform';
import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';
import { import {
CloudSyncType, CloudSyncType,
LauncherProjectData LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard'; } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { ProjectCreationWizard } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectCreationWizard';
import { import {
useGitHubRepos, useGitHubRepos,
NoodlGitHubRepo, NoodlGitHubRepo,
@@ -942,7 +942,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
onCloneRepo={handleCloneRepo} onCloneRepo={handleCloneRepo}
/> />
<CreateProjectModal <ProjectCreationWizard
isVisible={isCreateModalVisible} isVisible={isCreateModalVisible}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
onConfirm={handleCreateProjectConfirm} onConfirm={handleCreateProjectConfirm}

View File

@@ -0,0 +1,215 @@
/**
* ProjectCreationWizard — Unit tests for wizard state management
*
* Tests the step-sequencing logic and validation rules defined in WizardContext.
* These are pure logic tests — no DOM or React renderer required.
*
* The functions below mirror the private helpers in WizardContext.tsx.
* If the context logic changes, update both files.
*/
import { describe, it, expect } from '@jest/globals';
// ---- Step sequencing (mirrors WizardContext.getStepSequence) ---------------
function getStepSequence(mode) {
switch (mode) {
case 'quick':
return ['basics'];
case 'guided':
return ['basics', 'preset', 'review'];
case 'ai':
return ['basics', 'preset', 'review'];
default:
return ['basics'];
}
}
// ---- Validation (mirrors WizardContext.isStepValid) ------------------------
function isStepValid(step, state) {
switch (step) {
case 'entry':
return true;
case 'basics':
return state.projectName.trim().length > 0 && state.location.length > 0;
case 'preset':
return state.selectedPresetId.length > 0;
case 'review':
return true;
default:
return false;
}
}
// ---- Step navigation (mirrors WizardContext goNext/goBack logic) -----------
function goNext(state) {
if (state.currentStep === 'entry') {
const seq = getStepSequence(state.mode);
return seq[0];
}
const seq = getStepSequence(state.mode);
const idx = seq.indexOf(state.currentStep);
if (idx === -1 || idx >= seq.length - 1) return state.currentStep;
return seq[idx + 1];
}
function goBack(state) {
if (state.currentStep === 'entry') return 'entry';
const seq = getStepSequence(state.mode);
const idx = seq.indexOf(state.currentStep);
if (idx <= 0) return 'entry';
return seq[idx - 1];
}
// ---- Whether the current step is the last one before creation --------------
function isLastStep(mode, step) {
return step === 'review' || (mode === 'quick' && step === 'basics');
}
// ============================================================================
// Tests
// ============================================================================
describe('WizardContext: step sequences', () => {
it('quick mode only visits basics', () => {
expect(getStepSequence('quick')).toEqual(['basics']);
});
it('guided mode visits basics, preset, review', () => {
expect(getStepSequence('guided')).toEqual(['basics', 'preset', 'review']);
});
it('ai mode uses same sequence as guided (V1 stub)', () => {
expect(getStepSequence('ai')).toEqual(['basics', 'preset', 'review']);
});
});
describe('WizardContext: validation', () => {
const baseState = {
mode: 'quick',
currentStep: 'basics',
projectName: '',
description: '',
location: '',
selectedPresetId: 'modern'
};
it('entry step is always valid', () => {
expect(isStepValid('entry', { ...baseState, currentStep: 'entry' })).toBe(true);
});
it('review step is always valid', () => {
expect(isStepValid('review', { ...baseState, currentStep: 'review' })).toBe(true);
});
it('basics step requires projectName and location', () => {
expect(isStepValid('basics', baseState)).toBe(false);
});
it('basics step passes with name and location', () => {
expect(isStepValid('basics', { ...baseState, projectName: 'My Project', location: '/tmp' })).toBe(true);
});
it('basics step trims whitespace on projectName', () => {
expect(isStepValid('basics', { ...baseState, projectName: ' ', location: '/tmp' })).toBe(false);
});
it('preset step requires selectedPresetId', () => {
expect(isStepValid('preset', { ...baseState, selectedPresetId: '' })).toBe(false);
});
it('preset step passes with a preset id', () => {
expect(isStepValid('preset', { ...baseState, selectedPresetId: 'minimal' })).toBe(true);
});
});
describe('WizardContext: goNext navigation', () => {
const baseState = {
mode: 'quick',
currentStep: 'entry',
projectName: 'Test',
description: '',
location: '/tmp',
selectedPresetId: 'modern'
};
it('quick: entry advances to basics', () => {
expect(goNext({ ...baseState, mode: 'quick', currentStep: 'entry' })).toBe('basics');
});
it('quick: basics stays (is the last step)', () => {
expect(goNext({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('basics');
});
it('guided: entry advances to basics', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'entry' })).toBe('basics');
});
it('guided: basics advances to preset', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'basics' })).toBe('preset');
});
it('guided: preset advances to review', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'preset' })).toBe('review');
});
it('guided: review stays (is the last step)', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'review' })).toBe('review');
});
});
describe('WizardContext: goBack navigation', () => {
const baseState = {
mode: 'guided',
currentStep: 'review',
projectName: 'Test',
description: '',
location: '/tmp',
selectedPresetId: 'modern'
};
it('entry stays on entry when going back', () => {
expect(goBack({ ...baseState, currentStep: 'entry' })).toBe('entry');
});
it('guided: basics goes back to entry', () => {
expect(goBack({ ...baseState, currentStep: 'basics' })).toBe('entry');
});
it('guided: preset goes back to basics', () => {
expect(goBack({ ...baseState, currentStep: 'preset' })).toBe('basics');
});
it('guided: review goes back to preset', () => {
expect(goBack({ ...baseState, currentStep: 'review' })).toBe('preset');
});
it('quick: basics goes back to entry', () => {
expect(goBack({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('entry');
});
});
describe('isLastStep: determines when to show Create Project button', () => {
it('quick mode: basics is the last step', () => {
expect(isLastStep('quick', 'basics')).toBe(true);
});
it('quick mode: entry is not the last step', () => {
expect(isLastStep('quick', 'entry')).toBe(false);
});
it('guided mode: review is the last step', () => {
expect(isLastStep('guided', 'review')).toBe(true);
});
it('guided mode: basics is not the last step', () => {
expect(isLastStep('guided', 'basics')).toBe(false);
});
it('guided mode: preset is not the last step', () => {
expect(isLastStep('guided', 'preset')).toBe(false);
});
});