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 { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal, FormField, Input, Select, Textarea, Toggle, Button, Collapsible,
|
Modal, FormField, Input, Select, Textarea, Toggle, Button, Collapsible, ConfirmDialog,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { TapDefinition, ApplicationConfig } from '../api/queries/commands';
|
import type { TapDefinition, ApplicationConfig } from '../api/queries/commands';
|
||||||
import { useTestExpression } 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 [attrType, setAttrType] = useState<TapDefinition['attributeType']>('BUSINESS_OBJECT');
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [testTab, setTestTab] = useState('custom');
|
const [testTab, setTestTab] = useState('custom');
|
||||||
const [testPayload, setTestPayload] = useState('');
|
const [testPayload, setTestPayload] = useState('');
|
||||||
const [testResult, setTestResult] = useState<{ result?: string; error?: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ result?: string; error?: string } | null>(null);
|
||||||
@@ -118,6 +119,7 @@ export function TapConfigModal({
|
|||||||
if (tap && onDelete) {
|
if (tap && onDelete) {
|
||||||
onDelete(tap);
|
onDelete(tap);
|
||||||
onClose();
|
onClose();
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +251,24 @@ export function TapConfigModal({
|
|||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
{isEdit && onDelete && (
|
{isEdit && onDelete && (
|
||||||
<div className={styles.footerLeft}>
|
<div className={styles.footerLeft}>
|
||||||
<Button variant="danger" onClick={handleDelete}>Delete</Button>
|
<Button variant="danger" onClick={() => setShowDeleteConfirm(true)}>Delete</Button>
|
||||||
</div>
|
</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>
|
<Button variant="primary" onClick={handleSave} disabled={!name || !processor || !expression}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,10 +309,8 @@ export default function AppConfigDetailPage() {
|
|||||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/appconfig')}><ArrowLeft size={14} /> Back</Button>
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/appconfig')}><ArrowLeft size={14} /> Back</Button>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className={styles.toolbarActions}>
|
<div className={styles.toolbarActions}>
|
||||||
<Button onClick={handleSave} disabled={updateConfig.isPending}>
|
<Button variant="ghost" size="sm" onClick={cancelEditing}>Cancel</Button>
|
||||||
{updateConfig.isPending ? 'Saving\u2026' : 'Save'}
|
<Button variant="primary" size="sm" onClick={handleSave} loading={updateConfig.isPending}>Save</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" onClick={cancelEditing}>Cancel</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="secondary" size="sm" onClick={startEditing}><Pencil size={14} /> Edit</Button>
|
<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 type { Column } from '@cameleer/design-system';
|
||||||
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
|
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
|
||||||
import styles from './DatabaseAdminPage.module.css';
|
import styles from './DatabaseAdminPage.module.css';
|
||||||
import tableStyles from '../../styles/table-section.module.css';
|
import tableStyles from '../../styles/table-section.module.css';
|
||||||
|
|
||||||
export default function DatabaseAdminPage() {
|
export default function DatabaseAdminPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
const { data: status, isError: statusError } = useDatabaseStatus();
|
const { data: status, isError: statusError } = useDatabaseStatus();
|
||||||
const unreachable = statusError || (status && !status.connected);
|
const unreachable = statusError || (status && !status.connected);
|
||||||
const { data: pool } = useConnectionPool();
|
const { data: pool } = useConnectionPool();
|
||||||
const { data: tables } = useDatabaseTables();
|
const { data: tables } = useDatabaseTables();
|
||||||
const { data: queries } = useActiveQueries();
|
const { data: queries } = useActiveQueries();
|
||||||
const killQuery = useKillQuery();
|
const killQuery = useKillQuery();
|
||||||
|
const [killTarget, setKillTarget] = useState<number | null>(null);
|
||||||
|
|
||||||
const poolPct = pool ? (pool.activeConnections / pool.maximumPoolSize) * 100 : 0;
|
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: 'query', header: 'Query', render: (v) => <span className={styles.querySnippet}>{String(v).slice(0, 80)}</span> },
|
||||||
{
|
{
|
||||||
key: 'pid', header: '', width: '80px',
|
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>
|
</div>
|
||||||
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
|
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export default function OidcConfigPage() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
||||||
confirmText="delete oidc"
|
confirmText="delete oidc"
|
||||||
|
loading={saving}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<UserDetail | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<UserDetail | null>(null);
|
||||||
const [removeGroupTarget, setRemoveGroupTarget] = useState<string | 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
|
// Auto-select highlighted item from cmd-k navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -515,25 +516,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
|
|||||||
key={r.id}
|
key={r.id}
|
||||||
label={r.name}
|
label={r.name}
|
||||||
color="warning"
|
color="warning"
|
||||||
onRemove={() => {
|
onRemove={() => setRemoveRoleTarget(r)}
|
||||||
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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{inheritedRoles.map((r) => (
|
{inheritedRoles.map((r) => (
|
||||||
@@ -621,6 +604,31 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
|
|||||||
confirmLabel="Remove"
|
confirmLabel="Remove"
|
||||||
variant="warning"
|
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 { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router';
|
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||||
import {
|
import {
|
||||||
|
AlertDialog,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
@@ -506,6 +507,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [subTab, setSubTab] = useState<'overview' | 'config'>('config');
|
const [subTab, setSubTab] = useState<'overview' | 'config'>('config');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
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 envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
|
||||||
const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]);
|
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 }); }
|
} 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 {
|
try {
|
||||||
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId });
|
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
|
||||||
toast({ title: 'Deployment stopped', variant: 'warning' });
|
toast({ title: 'Deployment stopped', variant: 'warning' });
|
||||||
} catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); }
|
} catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); }
|
||||||
|
setStopTarget(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -602,6 +610,15 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
confirmText={app.slug}
|
confirmText={app.slug}
|
||||||
loading={deleteApp.isPending}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -982,7 +982,7 @@ export default function RouteDetail() {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<div className={styles.tapModalFooter}>
|
<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>
|
<Button variant="primary" onClick={saveTap} disabled={!tapName || !tapProcessor || !tapExpression}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -998,6 +998,7 @@ export default function RouteDetail() {
|
|||||||
confirmText={deletingTap?.attributeName ?? ''}
|
confirmText={deletingTap?.attributeName ?? ''}
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
loading={updateConfig.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user