fix: standardize button order, add confirmation dialogs for destructive actions

- Fix Cancel|Save order and add primary/loading props (AppConfigDetailPage)
- Add AlertDialog before stopping deployments (AppsTab)
- Add ConfirmDialog before deleting taps (TapConfigModal)
- Add AlertDialog before killing queries with toast feedback (DatabaseAdminPage)
- Add AlertDialog before removing roles from users (UsersTab)
- Standardize Cancel button to variant="ghost" (TapConfigModal, RouteDetail)
- Add loading prop to ConfirmDialogs (OidcConfigPage, RouteDetail)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 18:39:22 +02:00
parent 3d910af491
commit fb53dc6dfc
7 changed files with 95 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import {
Modal, FormField, Input, Select, Textarea, Toggle, Button, Collapsible,
Modal, FormField, Input, Select, Textarea, Toggle, Button, Collapsible, ConfirmDialog,
} from '@cameleer/design-system';
import type { TapDefinition, ApplicationConfig } from '../api/queries/commands';
import { useTestExpression } from '../api/queries/commands';
@@ -65,6 +65,7 @@ export function TapConfigModal({
const [attrType, setAttrType] = useState<TapDefinition['attributeType']>('BUSINESS_OBJECT');
const [enabled, setEnabled] = useState(true);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [testTab, setTestTab] = useState('custom');
const [testPayload, setTestPayload] = useState('');
const [testResult, setTestResult] = useState<{ result?: string; error?: string } | null>(null);
@@ -118,6 +119,7 @@ export function TapConfigModal({
if (tap && onDelete) {
onDelete(tap);
onClose();
setShowDeleteConfirm(false);
}
}
@@ -249,13 +251,24 @@ export function TapConfigModal({
<div className={styles.footer}>
{isEdit && onDelete && (
<div className={styles.footerLeft}>
<Button variant="danger" onClick={handleDelete}>Delete</Button>
<Button variant="danger" onClick={() => setShowDeleteConfirm(true)}>Delete</Button>
</div>
)}
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSave} disabled={!name || !processor || !expression}>Save</Button>
</div>
</div>
<ConfirmDialog
open={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
title="Delete Tap"
message={`Delete tap "${name}"? This will remove the tap from the configuration.`}
confirmText={name}
confirmLabel="Delete"
variant="danger"
/>
</Modal>
);
}

View File

@@ -309,10 +309,8 @@ export default function AppConfigDetailPage() {
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/appconfig')}><ArrowLeft size={14} /> Back</Button>
{editing ? (
<div className={styles.toolbarActions}>
<Button onClick={handleSave} disabled={updateConfig.isPending}>
{updateConfig.isPending ? 'Saving\u2026' : 'Save'}
</Button>
<Button variant="secondary" size="sm" onClick={cancelEditing}>Cancel</Button>
<Button variant="ghost" size="sm" onClick={cancelEditing}>Cancel</Button>
<Button variant="primary" size="sm" onClick={handleSave} loading={updateConfig.isPending}>Save</Button>
</div>
) : (
<Button variant="secondary" size="sm" onClick={startEditing}><Pencil size={14} /> Edit</Button>

View File

@@ -1,16 +1,19 @@
import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '@cameleer/design-system';
import { useState } from 'react';
import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner, AlertDialog, useToast } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
import styles from './DatabaseAdminPage.module.css';
import tableStyles from '../../styles/table-section.module.css';
export default function DatabaseAdminPage() {
const { toast } = useToast();
const { data: status, isError: statusError } = useDatabaseStatus();
const unreachable = statusError || (status && !status.connected);
const { data: pool } = useConnectionPool();
const { data: tables } = useDatabaseTables();
const { data: queries } = useActiveQueries();
const killQuery = useKillQuery();
const [killTarget, setKillTarget] = useState<number | null>(null);
const poolPct = pool ? (pool.activeConnections / pool.maximumPoolSize) * 100 : 0;
@@ -28,7 +31,7 @@ export default function DatabaseAdminPage() {
{ key: 'query', header: 'Query', render: (v) => <span className={styles.querySnippet}>{String(v).slice(0, 80)}</span> },
{
key: 'pid', header: '', width: '80px',
render: (v) => <Button variant="danger" size="sm" onClick={() => killQuery.mutate(v as number)}>Kill</Button>,
render: (v) => <Button variant="danger" size="sm" onClick={() => setKillTarget(v as number)}>Kill</Button>,
},
];
@@ -68,6 +71,29 @@ export default function DatabaseAdminPage() {
</div>
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
</div>
<AlertDialog
open={killTarget !== null}
onClose={() => setKillTarget(null)}
onConfirm={() => {
if (killTarget !== null) {
killQuery.mutate(killTarget, {
onSuccess: () => {
toast({ title: 'Query killed', description: `PID ${killTarget}`, variant: 'success' });
setKillTarget(null);
},
onError: () => {
toast({ title: 'Failed to kill query', variant: 'error', duration: 86_400_000 });
setKillTarget(null);
},
});
}
}}
title="Kill query?"
description={`Terminate the query running on PID ${killTarget}? This will cancel the operation.`}
confirmLabel="Kill"
variant="warning"
/>
</div>
);
}

View File

@@ -261,6 +261,7 @@ export default function OidcConfigPage() {
onConfirm={handleDelete}
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
confirmText="delete oidc"
loading={saving}
/>
</section>
</div>

View File

@@ -49,6 +49,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<UserDetail | null>(null);
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | null>(null);
const [removeRoleTarget, setRemoveRoleTarget] = useState<{ id: string; name: string } | null>(null);
// Auto-select highlighted item from cmd-k navigation
useEffect(() => {
@@ -515,25 +516,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
removeRole.mutate(
{ userId: selected.userId, roleId: r.id },
{
onSuccess: () =>
toast({
title: 'Role removed',
description: r.name,
variant: 'success',
}),
onError: () =>
toast({
title: 'Failed to remove role',
variant: 'error',
duration: 86_400_000,
}),
},
);
}}
onRemove={() => setRemoveRoleTarget(r)}
/>
))}
{inheritedRoles.map((r) => (
@@ -621,6 +604,31 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
confirmLabel="Remove"
variant="warning"
/>
<AlertDialog
open={removeRoleTarget !== null}
onClose={() => setRemoveRoleTarget(null)}
onConfirm={() => {
if (removeRoleTarget && selected) {
removeRole.mutate(
{ userId: selected.userId, roleId: removeRoleTarget.id },
{
onSuccess: () => {
toast({ title: 'Role removed', description: removeRoleTarget.name, variant: 'success' });
setRemoveRoleTarget(null);
},
onError: () => {
toast({ title: 'Failed to remove role', variant: 'error', duration: 86_400_000 });
setRemoveRoleTarget(null);
},
},
);
}
}}
title="Remove role"
description={`Remove the "${removeRoleTarget?.name}" role from this user?`}
confirmLabel="Remove"
variant="warning"
/>
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router';
import {
AlertDialog,
Badge,
Button,
ConfirmDialog,
@@ -506,6 +507,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
const fileInputRef = useRef<HTMLInputElement>(null);
const [subTab, setSubTab] = useState<'overview' | 'config'>('config');
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [stopTarget, setStopTarget] = useState<{ id: string; name: string } | null>(null);
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]);
@@ -531,11 +533,17 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
} catch { toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); }
}
async function handleStop(deploymentId: string) {
function handleStop(deploymentId: string) {
setStopTarget({ id: deploymentId, name: app?.displayName ?? appSlug });
}
async function confirmStop() {
if (!stopTarget) return;
try {
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId });
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
toast({ title: 'Deployment stopped', variant: 'warning' });
} catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); }
setStopTarget(null);
}
async function handleDelete() {
@@ -602,6 +610,15 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
confirmText={app.slug}
loading={deleteApp.isPending}
/>
<AlertDialog
open={!!stopTarget}
onClose={() => setStopTarget(null)}
onConfirm={confirmStop}
title="Stop deployment?"
description={`Stop deployment for "${stopTarget?.name}"? This will take the service offline.`}
confirmLabel="Stop"
variant="warning"
/>
</div>
);
}

View File

@@ -982,7 +982,7 @@ export default function RouteDetail() {
</Collapsible>
<div className={styles.tapModalFooter}>
<Button variant="secondary" onClick={() => setTapModalOpen(false)}>Cancel</Button>
<Button variant="ghost" onClick={() => setTapModalOpen(false)}>Cancel</Button>
<Button variant="primary" onClick={saveTap} disabled={!tapName || !tapProcessor || !tapExpression}>Save</Button>
</div>
</div>
@@ -998,6 +998,7 @@ export default function RouteDetail() {
confirmText={deletingTap?.attributeName ?? ''}
confirmLabel="Delete"
variant="danger"
loading={updateConfig.isPending}
/>
</div>
);