feat: single-step app creation with auto-slug, JAR upload, and deploy
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user