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;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 12px;
|
}
|
||||||
margin-bottom: 12px;
|
|
||||||
border: 1px solid var(--border-subtle);
|
.fileName {
|
||||||
border-radius: 6px;
|
font-size: 12px;
|
||||||
background: var(--bg-raised);
|
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 {
|
.createActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nativeSelect {
|
.nativeSelect {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
DataTable,
|
DataTable,
|
||||||
Input,
|
Input,
|
||||||
|
Modal,
|
||||||
MonoText,
|
MonoText,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
Select,
|
Select,
|
||||||
@@ -53,6 +54,14 @@ const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | '
|
|||||||
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: '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() {
|
export default function AppsTab() {
|
||||||
const { appId } = useParams<{ appId?: string }>();
|
const { appId } = useParams<{ appId?: string }>();
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
@@ -72,12 +81,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
|
|||||||
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
||||||
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
|
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
|
||||||
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
|
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [newSlug, setNewSlug] = useState('');
|
|
||||||
const [newDisplayName, setNewDisplayName] = useState('');
|
|
||||||
const [newEnvId, setNewEnvId] = useState('');
|
|
||||||
const createApp = useCreateApp();
|
|
||||||
|
|
||||||
const apps = selectedEnv ? envApps : allApps;
|
const apps = selectedEnv ? envApps : allApps;
|
||||||
const isLoading = selectedEnv ? envLoading : allLoading;
|
const isLoading = selectedEnv ? envLoading : allLoading;
|
||||||
@@ -107,41 +111,146 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde
|
|||||||
},
|
},
|
||||||
], [selectedEnv]);
|
], [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" />;
|
if (isLoading) return <Spinner size="md" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<Button size="sm" variant="primary" onClick={() => {
|
<Button size="sm" variant="primary" onClick={() => setCreateOpen(true)}>+ Create App</Button>
|
||||||
if (!newEnvId && environments.length > 0) setNewEnvId(environments[0].id);
|
|
||||||
setCreating(!creating);
|
|
||||||
}}>+ Create App</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{creating && (
|
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
|
||||||
<div className={styles.createForm}>
|
<CreateAppModal
|
||||||
<select className={styles.nativeSelect} value={newEnvId} onChange={(e) => setNewEnvId(e.target.value)}>
|
open={createOpen}
|
||||||
{environments.map((env) => <option key={env.id} value={env.id}>{env.displayName} ({env.slug})</option>)}
|
onClose={() => setCreateOpen(false)}
|
||||||
</select>
|
environments={environments}
|
||||||
<Input placeholder="Slug *" value={newSlug} onChange={(e) => setNewSlug(e.target.value)} />
|
defaultEnvId={envId}
|
||||||
<Input placeholder="Display name *" value={newDisplayName} onChange={(e) => setNewDisplayName(e.target.value)} />
|
onCreated={(appId) => { setCreateOpen(false); navigate(`/apps/${appId}`); }}
|
||||||
<div className={styles.createActions}>
|
/>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
</div>
|
||||||
<Button size="sm" variant="primary" onClick={handleCreate} loading={createApp.isPending}
|
);
|
||||||
disabled={!newSlug.trim() || !newDisplayName.trim() || !newEnvId}>Create</Button>
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
|
<div className={styles.createField}>
|
||||||
</div>
|
<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