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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user