Files
OpenNoodl/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-001B-launcher-fixes/DASH-001B-create-project-modal.md

12 KiB

DASH-001B-4: Create Project Modal

Overview

Replace the basic browser prompt() dialog with a proper React modal for creating new projects. Provides name input and folder picker in a clean UI.

Problem

Current implementation uses a browser prompt:

const name = prompt('Project name:'); // ❌ Bad UX
if (!name) return;

Issues:

  • Poor UX (browser native prompt looks outdated)
  • No validation feedback
  • No folder selection context
  • Doesn't match app design
  • Not accessible

Solution

Create a React modal component with:

  • Project name input field
  • Folder picker button
  • Validation (name required, path valid)
  • Cancel/Create buttons
  • Proper styling matching launcher theme

Component Design

Modal Structure

┌─────────────────────────────────────────────┐
│  Create New Project                    ✕    │
├─────────────────────────────────────────────┤
│                                             │
│  Project Name                               │
│  ┌─────────────────────────────────────┐   │
│  │ My New Project                      │   │
│  └─────────────────────────────────────┘   │
│                                             │
│  Location                                   │
│  ┌──────────────────────────────┐ [Choose] │
│  │ ~/Documents/Noodl Projects/  │          │
│  └──────────────────────────────┘          │
│                                             │
│  Full path: ~/Documents/Noodl Projects/    │
│             My New Project/                 │
│                                             │
│                    [Cancel]  [Create]       │
└─────────────────────────────────────────────┘

Props Interface

export interface CreateProjectModalProps {
  isVisible: boolean;
  onClose: () => void;
  onConfirm: (name: string, location: string) => void;
}

Implementation Steps

1. Create CreateProjectModal component

File: packages/noodl-core-ui/src/preview/launcher/Launcher/components/CreateProjectModal/CreateProjectModal.tsx

import React, { useState, useEffect } from 'react';

import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { BaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
import { Label } from '@noodl-core-ui/components/typography/Label';
import { Text } from '@noodl-core-ui/components/typography/Text';

import css from './CreateProjectModal.module.scss';

export interface CreateProjectModalProps {
  isVisible: boolean;
  onClose: () => void;
  onConfirm: (name: string, location: string) => void;
  onChooseLocation?: () => Promise<string | null>; // For folder picker
}

export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLocation }: CreateProjectModalProps) {
  const [projectName, setProjectName] = useState('');
  const [location, setLocation] = useState('');
  const [isChoosingLocation, setIsChoosingLocation] = useState(false);

  // Reset state when modal opens
  useEffect(() => {
    if (isVisible) {
      setProjectName('');
      setLocation('');
    }
  }, [isVisible]);

  const handleChooseLocation = async () => {
    if (!onChooseLocation) return;

    setIsChoosingLocation(true);
    try {
      const chosen = await onChooseLocation();
      if (chosen) {
        setLocation(chosen);
      }
    } finally {
      setIsChoosingLocation(false);
    }
  };

  const handleCreate = () => {
    if (!projectName.trim() || !location) return;
    onConfirm(projectName.trim(), location);
  };

  const isValid = projectName.trim().length > 0 && location.length > 0;

  if (!isVisible) return null;

  return (
    <BaseDialog
      isVisible={isVisible}
      title="Create New Project"
      onClose={onClose}
      onPrimaryAction={handleCreate}
      primaryActionLabel="Create"
      primaryActionDisabled={!isValid}
      onSecondaryAction={onClose}
      secondaryActionLabel="Cancel"
    >
      <div className={css['Content']}>
        {/* Project Name */}
        <div className={css['Field']}>
          <Label>Project Name</Label>
          <TextInput
            value={projectName}
            onChange={(e) => setProjectName(e.target.value)}
            placeholder="My New Project"
            autoFocus
            UNSAFE_style={{ marginTop: 'var(--spacing-2)' }}
          />
        </div>

        {/* Location */}
        <div className={css['Field']}>
          <Label>Location</Label>
          <div className={css['LocationRow']}>
            <TextInput
              value={location}
              onChange={(e) => setLocation(e.target.value)}
              placeholder="Choose folder..."
              readOnly
              UNSAFE_style={{ flex: 1 }}
            />
            <PrimaryButton
              label="Choose..."
              size={PrimaryButtonSize.Small}
              variant={PrimaryButtonVariant.Muted}
              onClick={handleChooseLocation}
              isDisabled={isChoosingLocation}
              UNSAFE_style={{ marginLeft: 'var(--spacing-2)' }}
            />
          </div>
        </div>

        {/* Preview full path */}
        {projectName && location && (
          <div className={css['PathPreview']}>
            <Text variant="shy">
              Full path: {location}/{projectName}/
            </Text>
          </div>
        )}
      </div>
    </BaseDialog>
  );
}

2. Create styles

File: packages/noodl-core-ui/src/preview/launcher/Launcher/components/CreateProjectModal/CreateProjectModal.module.scss

.Content {
  min-width: 400px;
  padding: var(--spacing-4) 0;
}

.Field {
  margin-bottom: var(--spacing-4);

  &:last-child {
    margin-bottom: 0;
  }
}

.LocationRow {
  display: flex;
  align-items: center;
  margin-top: var(--spacing-2);
}

.PathPreview {
  margin-top: var(--spacing-3);
  padding: var(--spacing-3);
  background-color: var(--theme-color-bg-3);
  border-radius: var(--radius-default);
  border: 1px solid var(--theme-color-border-default);
}

3. Create index export

File: packages/noodl-core-ui/src/preview/launcher/Launcher/components/CreateProjectModal/index.ts

export { CreateProjectModal } from './CreateProjectModal';
export type { CreateProjectModalProps } from './CreateProjectModal';

4. Update ProjectsPage to use modal

File: packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx

Replace prompt-based flow with modal:

import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';

export function ProjectsPage(props: ProjectsPageProps) {
  // ... existing code

  // Add state for modal
  const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);

  const handleCreateProject = useCallback(() => {
    // Open modal instead of prompt
    setIsCreateModalVisible(true);
  }, []);

  const handleChooseLocation = useCallback(async (): Promise<string | null> => {
    try {
      const direntry = await filesystem.openDialog({
        allowCreateDirectory: true
      });
      return direntry || null;
    } catch (error) {
      console.error('Failed to choose location:', error);
      return null;
    }
  }, []);

  const handleCreateProjectConfirm = useCallback(
    async (name: string, location: string) => {
      setIsCreateModalVisible(false);

      try {
        const path = filesystem.makeUniquePath(filesystem.join(location, name));

        const activityId = 'creating-project';
        ToastLayer.showActivity('Creating new project', activityId);

        LocalProjectsModel.instance.newProject(
          (project) => {
            ToastLayer.hideActivity(activityId);
            if (!project) {
              ToastLayer.showError('Could not create project');
              return;
            }
            // Navigate to editor with the newly created project
            props.route.router.route({ to: 'editor', project });
          },
          { name, path, projectTemplate: '' }
        );
      } catch (error) {
        console.error('Failed to create project:', error);
        ToastLayer.showError('Failed to create project');
      }
    },
    [props.route]
  );

  const handleCreateModalClose = useCallback(() => {
    setIsCreateModalVisible(false);
  }, []);

  // ... existing code

  return (
    <>
      <Launcher
        projects={realProjects}
        onCreateProject={handleCreateProject}
        onOpenProject={handleOpenProject}
        onLaunchProject={handleLaunchProject}
        onOpenProjectFolder={handleOpenProjectFolder}
        onDeleteProject={handleDeleteProject}
        projectOrganizationService={ProjectOrganizationService.instance}
        githubUser={githubUser}
        githubIsAuthenticated={githubIsAuthenticated}
        githubIsConnecting={githubIsConnecting}
        onGitHubConnect={handleGitHubConnect}
        onGitHubDisconnect={handleGitHubDisconnect}
      />

      {/* Add modal */}
      <CreateProjectModal
        isVisible={isCreateModalVisible}
        onClose={handleCreateModalClose}
        onConfirm={handleCreateProjectConfirm}
        onChooseLocation={handleChooseLocation}
      />
    </>
  );
}

Files to Create

  1. packages/noodl-core-ui/src/preview/launcher/Launcher/components/CreateProjectModal/CreateProjectModal.tsx
  2. packages/noodl-core-ui/src/preview/launcher/Launcher/components/CreateProjectModal/CreateProjectModal.module.scss
  3. packages/noodl-core-ui/src/preview/launcher/Launcher/components/CreateProjectModal/index.ts

Files to Modify

  1. packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx

Testing Checklist

  • Click "Create new project" button
  • Modal appears with focus on name input
  • Can type project name
  • Create button disabled until name and location provided
  • Click "Choose..." button
  • Folder picker dialog appears
  • Selected folder displays in location field
  • Full path preview shows correctly
  • Click Cancel closes modal without action
  • Click Create with valid inputs creates project
  • Navigate to editor after successful creation
  • Invalid input shows appropriate feedback

Validation Rules

  1. Project name:

    • Must not be empty
    • Trim whitespace
    • Allow any characters (filesystem will sanitize if needed)
  2. Location:

    • Must not be empty
    • Must be a valid directory path
    • User must select via picker (not manual entry)
  3. Full path:

    • Combination of location + name
    • Must be unique (handled by filesystem.makeUniquePath)

Benefits

  1. Better UX - Modern modal matches app design
  2. Visual feedback - See full path before creating
  3. Validation - Clear indication of required fields
  4. Accessibility - Proper keyboard navigation
  5. Consistent - Uses existing UI components

Future Enhancements (Phase 8)

This modal is intentionally minimal. Phase 8 WIZARD-001 will add:

  • Template selection
  • Git initialization option
  • AI-assisted project setup
  • Multi-step wizard flow

Edge Cases

Location picker cancelled

If user cancels the folder picker, the location field remains unchanged (keeps previous value or stays empty).

Invalid name characters

The filesystem will handle sanitization if the name contains invalid characters for the OS.

Path already exists

filesystem.makeUniquePath() automatically appends a number if the path exists (e.g., "My Project (2)").

Follow-up

This completes the TASK-001B fixes. After all subtasks are implemented, verify:

  • Folders persist after restart
  • Folders appear in modal
  • Only grid view visible
  • Project creation uses modal

Estimated Time: 2-3 hours Status: Not Started