mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user