mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ProjectCreationWizard } from './ProjectCreationWizard';
|
||||||
|
export type { ProjectCreationWizardProps } from './ProjectCreationWizard';
|
||||||
|
export type { WizardMode, WizardStep, WizardState } from './WizardContext';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
215
packages/noodl-editor/tests/models/ProjectCreationWizard.test.ts
Normal file
215
packages/noodl-editor/tests/models/ProjectCreationWizard.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user