Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/projects"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
Expand All @@ -57,14 +49,6 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/projects/new"
element={
<ProtectedRoute>
<CreateWorkspace mode="project" />
</ProtectedRoute>
}
/>
<Route
path="/workspaces/:id"
element={
Expand Down
172 changes: 145 additions & 27 deletions apps/web/src/components/project/ProjectForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useMemo, useState, type FormEvent } from 'react';
import { useCallback, useMemo, useState, type FormEvent } from 'react';
import type { GitHubInstallation } from '@simple-agent-manager/shared';
import { Button, Input } from '@simple-agent-manager/ui';
import { Button, Input, Spinner } from '@simple-agent-manager/ui';
import { listBranches } from '../../lib/api';
import { RepoSelector } from '../RepoSelector';

export interface ProjectFormValues {
name: string;
Expand All @@ -20,6 +22,18 @@ interface ProjectFormProps {
submitLabel?: string;
}

function normalizeRepository(value: string): string {
let repository = value.trim();

if (repository.startsWith('https://github.com/')) {
repository = repository.replace('https://github.com/', '');
} else if (repository.startsWith('git@github.com:')) {
repository = repository.replace('git@github.com:', '');
}

return repository.replace(/\.git$/, '');
}

export function ProjectForm({
mode,
installations,
Expand All @@ -43,14 +57,75 @@ export function ProjectForm({
repository: initialValues?.repository ?? '',
defaultBranch: initialValues?.defaultBranch ?? 'main',
});
const [branches, setBranches] = useState<Array<{ name: string }>>([]);
const [branchesLoading, setBranchesLoading] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const isEditMode = mode === 'edit';

const fetchBranches = useCallback(async (repository: string, installationId: string) => {
setBranchesLoading(true);
setBranches([]);
setBranchesError(null);

try {
const result = await listBranches(repository, installationId || undefined);
setBranches(result);

if (result.length === 0) {
setBranches([{ name: 'main' }, { name: 'master' }]);
setBranchesError('Could not fetch branches, showing common defaults');
}
} catch {
setBranches([{ name: 'main' }, { name: 'master' }, { name: 'develop' }]);
setBranchesError('Unable to fetch branches. Common branch names provided.');
} finally {
setBranchesLoading(false);
}
}, []);

const handleChange = (field: keyof ProjectFormValues, value: string) => {
setValues((current) => ({ ...current, [field]: value }));
};

const handleRepositoryChange = (value: string) => {
setBranches([]);
setBranchesError(null);
handleChange('repository', value);
};

const handleRepoSelect = useCallback(
(repo: { fullName: string; defaultBranch: string } | null) => {
if (!repo) {
setBranches([]);
setBranchesError(null);
return;
}

setValues((current) => ({ ...current, defaultBranch: repo.defaultBranch }));
void fetchBranches(repo.fullName, values.installationId);
},
[fetchBranches, values.installationId]
);

const handleInstallationChange = (installationId: string) => {
handleChange('installationId', installationId);

if (isEditMode) {
return;
}

const normalizedRepository = normalizeRepository(values.repository);
if (!normalizedRepository || !normalizedRepository.includes('/')) {
setBranches([]);
setBranchesError(null);
return;
}

void fetchBranches(normalizedRepository, installationId);
};

const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
Expand Down Expand Up @@ -79,11 +154,21 @@ export function ProjectForm({
name: values.name.trim(),
description: values.description.trim(),
installationId: values.installationId,
repository: values.repository.trim(),
repository: normalizeRepository(values.repository),
defaultBranch: values.defaultBranch.trim(),
});
};

const selectStyle = {
width: '100%',
borderRadius: 'var(--sam-radius-md)',
border: '1px solid var(--sam-color-border-default)',
background: 'var(--sam-color-bg-surface)',
color: 'var(--sam-color-fg-primary)',
padding: '0.625rem 0.75rem',
minHeight: '2.75rem',
};

return (
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: 'var(--sam-space-3)' }}>
<label style={{ display: 'grid', gap: '0.375rem' }}>
Expand Down Expand Up @@ -119,17 +204,9 @@ export function ProjectForm({
<span style={{ fontSize: '0.875rem', color: 'var(--sam-color-fg-muted)' }}>Installation</span>
<select
value={values.installationId}
onChange={(event) => handleChange('installationId', event.currentTarget.value)}
onChange={(event) => handleInstallationChange(event.currentTarget.value)}
disabled={submitting || isEditMode}
style={{
width: '100%',
borderRadius: 'var(--sam-radius-md)',
border: '1px solid var(--sam-color-border-default)',
background: 'var(--sam-color-bg-surface)',
color: 'var(--sam-color-fg-primary)',
padding: '0.625rem 0.75rem',
minHeight: '2.75rem',
}}
style={selectStyle}
>
{installations.length === 0 ? (
<option value="">No installations</option>
Expand All @@ -143,24 +220,65 @@ export function ProjectForm({
</select>
</label>

<label style={{ display: 'grid', gap: '0.375rem' }}>
<label htmlFor="project-repository" style={{ display: 'grid', gap: '0.375rem' }}>
<span style={{ fontSize: '0.875rem', color: 'var(--sam-color-fg-muted)' }}>Repository</span>
<Input
value={values.repository}
onChange={(event) => handleChange('repository', event.currentTarget.value)}
placeholder="owner/repo"
disabled={submitting || isEditMode}
/>
{isEditMode ? (
<Input
id="project-repository"
value={values.repository}
onChange={(event) => handleChange('repository', event.currentTarget.value)}
placeholder="owner/repo"
disabled
/>
) : (
<RepoSelector
id="project-repository"
value={values.repository}
onChange={handleRepositoryChange}
onRepoSelect={handleRepoSelect}
disabled={submitting}
required
/>
)}
</label>

<label style={{ display: 'grid', gap: '0.375rem' }}>
<label htmlFor="project-default-branch" style={{ display: 'grid', gap: '0.375rem' }}>
<span style={{ fontSize: '0.875rem', color: 'var(--sam-color-fg-muted)' }}>Default branch</span>
<Input
value={values.defaultBranch}
onChange={(event) => handleChange('defaultBranch', event.currentTarget.value)}
placeholder="main"
disabled={submitting}
/>
<div style={{ position: 'relative' }}>
{!isEditMode && branches.length > 0 ? (
<select
id="project-default-branch"
value={values.defaultBranch}
onChange={(event) => handleChange('defaultBranch', event.currentTarget.value)}
disabled={submitting}
style={selectStyle}
>
{branches.map((branch) => (
<option key={branch.name} value={branch.name}>
{branch.name}
</option>
))}
</select>
) : (
<Input
id="project-default-branch"
value={values.defaultBranch}
onChange={(event) => handleChange('defaultBranch', event.currentTarget.value)}
placeholder="main"
disabled={submitting}
/>
)}
{!isEditMode && branchesLoading && (
<div style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)' }}>
<Spinner size="sm" />
</div>
)}
</div>
{!isEditMode && branchesError && (
<span style={{ fontSize: '0.75rem', color: 'var(--sam-color-fg-muted)' }}>
{branchesError}
</span>
)}
</label>

{error && (
Expand Down
21 changes: 7 additions & 14 deletions apps/web/src/pages/CreateWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,10 @@ type LocationState = {
nodeId?: string;
};

interface CreateWorkspaceProps {
mode?: 'workspace' | 'project';
}

export function CreateWorkspace({ mode = 'workspace' }: CreateWorkspaceProps) {
export function CreateWorkspace() {
const navigate = useNavigate();
const location = useLocation();
const locationState = (location.state as LocationState | null) ?? null;
const entityLabel = mode === 'project' ? 'Project' : 'Workspace';
const entityLabelLower = entityLabel.toLowerCase();
const listPath = mode === 'project' ? '/projects' : '/dashboard';

const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -245,8 +238,8 @@ export function CreateWorkspace({ mode = 'workspace' }: CreateWorkspaceProps) {

return (
<PageLayout
title={`Create ${entityLabel}`}
onBack={() => navigate(listPath)}
title="Create Workspace"
onBack={() => navigate('/dashboard')}
maxWidth="md"
headerRight={<UserMenu />}
>
Expand All @@ -258,7 +251,7 @@ export function CreateWorkspace({ mode = 'workspace' }: CreateWorkspaceProps) {
</h3>
{!checkingPrereqs && anyMissing && (
<p style={{ margin: '4px 0 0', fontSize: '0.8125rem', color: 'var(--sam-color-fg-muted)' }}>
Complete the items below before creating a {entityLabelLower}.
Complete the items below before creating a workspace.
</p>
)}
</div>
Expand Down Expand Up @@ -319,7 +312,7 @@ export function CreateWorkspace({ mode = 'workspace' }: CreateWorkspaceProps) {

<div>
<label htmlFor="name" style={labelStyle}>
{entityLabel} Name
Workspace Name
</label>
<Input
id="name"
Expand Down Expand Up @@ -482,11 +475,11 @@ export function CreateWorkspace({ mode = 'workspace' }: CreateWorkspaceProps) {
paddingTop: 'var(--sam-space-4)',
}}
>
<Button type="button" onClick={() => navigate(listPath)} variant="secondary" size="md">
<Button type="button" onClick={() => navigate('/dashboard')} variant="secondary" size="md">
Cancel
</Button>
<Button type="submit" disabled={loading || !name || !repository} size="lg" loading={loading}>
Create {entityLabel}
Create Workspace
</Button>
</div>
</form>
Expand Down
74 changes: 74 additions & 0 deletions apps/web/tests/unit/app-routes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

vi.mock('../../src/components/AuthProvider', () => ({
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

vi.mock('../../src/components/ErrorBoundary', () => ({
ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

vi.mock('../../src/hooks/useToast', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

vi.mock('../../src/components/ProtectedRoute', () => ({
ProtectedRoute: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

vi.mock('../../src/pages/Landing', () => ({
Landing: () => <div data-testid="landing-page" />,
}));

vi.mock('../../src/pages/Dashboard', () => ({
Dashboard: () => <div data-testid="dashboard-page" />,
}));

vi.mock('../../src/pages/Settings', () => ({
Settings: () => <div data-testid="settings-page" />,
}));

vi.mock('../../src/pages/CreateWorkspace', () => ({
CreateWorkspace: () => <div data-testid="create-workspace-page" />,
}));

vi.mock('../../src/pages/Workspace', () => ({
Workspace: () => <div data-testid="workspace-page" />,
}));

vi.mock('../../src/pages/Nodes', () => ({
Nodes: () => <div data-testid="nodes-page" />,
}));

vi.mock('../../src/pages/Node', () => ({
Node: () => <div data-testid="node-page" />,
}));

vi.mock('../../src/pages/UiStandards', () => ({
UiStandards: () => <div data-testid="ui-standards-page" />,
}));

vi.mock('../../src/pages/Projects', () => ({
Projects: () => <div data-testid="projects-page" />,
}));

vi.mock('../../src/pages/Project', () => ({
Project: () => <div data-testid="project-detail-page" />,
}));

import App from '../../src/App';

function renderAt(path: string) {
window.history.pushState({}, '', path);
return render(<App />);
}

describe('App routes', () => {
it('routes /projects to the Projects page', () => {
renderAt('/projects');

expect(screen.getByTestId('projects-page')).toBeInTheDocument();
expect(screen.queryByTestId('dashboard-page')).not.toBeInTheDocument();
});
});
Loading
Loading