feat: single-step app creation with auto-slug, JAR upload, and deploy
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m20s
CI / docker (push) Successful in 1m5s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

Replace inline create form with a modal that handles the full flow:
- Name → auto-computed slug (editable if needed)
- Environment picker
- JAR file upload
- "Deploy immediately" toggle (on by default)
- Single "Create & Deploy" button runs all three API calls sequentially
  with step indicator

After creation, navigates directly to the new app's detail view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 17:48:20 +02:00
parent c4fe992179
commit 6a32b83326
2 changed files with 192 additions and 40 deletions

View File

@@ -10,20 +10,63 @@
margin-bottom: 12px;
}
.createForm {
/* Create modal */
.createModal {
display: flex;
flex-direction: column;
gap: 14px;
}
.createField {
display: flex;
flex-direction: column;
gap: 4px;
}
.createLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.createLabelHint {
font-weight: 400;
font-style: italic;
margin-left: 6px;
}
.fileRow {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
margin-bottom: 12px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--bg-raised);
gap: 10px;
}
.fileName {
font-size: 12px;
color: var(--text-primary);
font-family: var(--font-mono);
}
.deployToggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-primary);
}
.stepIndicator {
font-size: 12px;
color: var(--accent, #6c7aff);
font-style: italic;
}
.createActions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid var(--border-subtle);
}
.nativeSelect {

View File

@@ -5,6 +5,7 @@ import {
Button,
DataTable,
Input,
Modal,
MonoText,
SectionHeader,
Select,
@@ -53,6 +54,14 @@ const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | '
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
};
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 100);
}
export default function AppsTab() {
const { appId } = useParams<{ appId?: string }>();
const selectedEnv = useEnvironmentStore((s) => s.environment);
@@ -72,12 +81,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
const { data: allApps = [], isLoading: allLoading } = useAllApps();
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
const [creating, setCreating] = useState(false);
const [newSlug, setNewSlug] = useState('');
const [newDisplayName, setNewDisplayName] = useState('');
const [newEnvId, setNewEnvId] = useState('');
const createApp = useCreateApp();
const [createOpen, setCreateOpen] = useState(false);
const apps = selectedEnv ? envApps : allApps;
const isLoading = selectedEnv ? envLoading : allLoading;
@@ -107,41 +111,146 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
},
], [selectedEnv]);
async function handleCreate() {
if (!newSlug.trim() || !newDisplayName.trim() || !newEnvId) return;
try {
await createApp.mutateAsync({ environmentId: newEnvId, slug: newSlug.trim(), displayName: newDisplayName.trim() });
toast({ title: 'App created', description: newSlug.trim(), variant: 'success' });
setCreating(false); setNewSlug(''); setNewDisplayName(''); setNewEnvId('');
} catch { toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 }); }
}
if (isLoading) return <Spinner size="md" />;
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<Button size="sm" variant="primary" onClick={() => {
if (!newEnvId && environments.length > 0) setNewEnvId(environments[0].id);
setCreating(!creating);
}}>+ Create App</Button>
<Button size="sm" variant="primary" onClick={() => setCreateOpen(true)}>+ Create App</Button>
</div>
{creating && (
<div className={styles.createForm}>
<select className={styles.nativeSelect} value={newEnvId} onChange={(e) => setNewEnvId(e.target.value)}>
{environments.map((env) => <option key={env.id} value={env.id}>{env.displayName} ({env.slug})</option>)}
</select>
<Input placeholder="Slug *" value={newSlug} onChange={(e) => setNewSlug(e.target.value)} />
<Input placeholder="Display name *" value={newDisplayName} onChange={(e) => setNewDisplayName(e.target.value)} />
<div className={styles.createActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleCreate} loading={createApp.isPending}
disabled={!newSlug.trim() || !newDisplayName.trim() || !newEnvId}>Create</Button>
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
<CreateAppModal
open={createOpen}
onClose={() => setCreateOpen(false)}
environments={environments}
defaultEnvId={envId}
onCreated={(appId) => { setCreateOpen(false); navigate(`/apps/${appId}`); }}
/>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// CREATE APP MODAL
// ═══════════════════════════════════════════════════════════════════
function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }: {
open: boolean;
onClose: () => void;
environments: Environment[];
defaultEnvId: string | undefined;
onCreated: (appId: string) => void;
}) {
const { toast } = useToast();
const createApp = useCreateApp();
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const [name, setName] = useState('');
const [slugEdited, setSlugEdited] = useState(false);
const [slug, setSlug] = useState('');
const [envId, setEnvId] = useState('');
const [file, setFile] = useState<File | null>(null);
const [deploy, setDeploy] = useState(true);
const [busy, setBusy] = useState(false);
const [step, setStep] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Reset on open
useEffect(() => {
if (open) {
setName(''); setSlug(''); setSlugEdited(false); setFile(null);
setDeploy(true); setBusy(false); setStep('');
setEnvId(defaultEnvId || (environments.length > 0 ? environments[0].id : ''));
}
}, [open, defaultEnvId, environments]);
// Auto-compute slug from name
useEffect(() => {
if (!slugEdited) setSlug(slugify(name));
}, [name, slugEdited]);
const canSubmit = name.trim() && slug.trim() && envId && file;
async function handleSubmit() {
if (!canSubmit) return;
setBusy(true);
try {
// 1. Create app
setStep('Creating app...');
const app = await createApp.mutateAsync({ environmentId: envId, slug: slug.trim(), displayName: name.trim() });
// 2. Upload JAR
setStep('Uploading JAR...');
const version = await uploadJar.mutateAsync({ appId: app.id, file: file! });
// 3. Deploy (if requested)
if (deploy) {
setStep('Starting deployment...');
await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId });
}
toast({ title: 'App created and deployed', description: name.trim(), variant: 'success' });
onCreated(app.id);
} catch (e) {
toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 });
} finally {
setBusy(false);
setStep('');
}
}
return (
<Modal open={open} onClose={onClose} title="Create Application" size="md">
<div className={styles.createModal}>
<div className={styles.createField}>
<label className={styles.createLabel}>Application Name</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} />
</div>
<div className={styles.createField}>
<label className={styles.createLabel}>
Slug
<span className={styles.createLabelHint}>(auto-generated, editable)</span>
</label>
<Input value={slug} onChange={(e) => { setSlug(e.target.value); setSlugEdited(true); }} disabled={busy} />
</div>
<div className={styles.createField}>
<label className={styles.createLabel}>Environment</label>
<Select value={envId} onChange={(e) => setEnvId(e.target.value)} disabled={busy}
options={environments.filter((e) => e.enabled).map((e) => ({ value: e.id, label: `${e.displayName} (${e.slug})` }))} />
</div>
<div className={styles.createField}>
<label className={styles.createLabel}>Application JAR</label>
<div className={styles.fileRow}>
<input ref={fileInputRef} type="file" accept=".jar" style={{ display: 'none' }}
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={busy}>
{file ? 'Change file' : 'Select JAR'}
</Button>
{file && <span className={styles.fileName}>{file.name} ({formatBytes(file.size)})</span>}
</div>
</div>
)}
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
</div>
<div className={styles.createField}>
<div className={styles.deployToggle}>
<Toggle checked={deploy} onChange={() => setDeploy(!deploy)} disabled={busy} />
<span>{deploy ? 'Deploy immediately after upload' : 'Upload only (deploy later)'}</span>
</div>
</div>
{step && <div className={styles.stepIndicator}>{step}</div>}
<div className={styles.createActions}>
<Button size="sm" variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleSubmit} loading={busy} disabled={!canSubmit || busy}>
{deploy ? 'Create & Deploy' : 'Create & Upload'}
</Button>
</div>
</div>
</Modal>
);
}