feat(ui): require confirmation before delete actions
- add a reusable modal component and export it from the shared ui barrel - gate all delete and remove flows behind explicit confirmation dialogs - use a solid modal surface color token with fallback to avoid transparent body
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -23,6 +24,8 @@ export function CredentialDetailPage() {
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -35,8 +38,14 @@ export function CredentialDetailPage() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!credential) return;
|
||||
await credentials.remove(credential.id);
|
||||
navigate('/credentials');
|
||||
setDeleting(true);
|
||||
try {
|
||||
await credentials.remove(credential.id);
|
||||
navigate('/credentials');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -74,7 +83,7 @@ export function CredentialDetailPage() {
|
||||
<Pencil size={14} />
|
||||
{t('credentials.action_edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleDelete}>
|
||||
<Button variant="danger" size="sm" onClick={() => setConfirmDeleteOpen(true)}>
|
||||
<Trash2 size={14} />
|
||||
{t('credentials.action_delete')}
|
||||
</Button>
|
||||
@@ -126,6 +135,24 @@ export function CredentialDetailPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={confirmDeleteOpen}
|
||||
title={t('credentials.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('credentials.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this credential?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Card,
|
||||
ContextMenu,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -103,6 +104,8 @@ export function CredentialsPage() {
|
||||
const [items, setItems] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => {
|
||||
@@ -117,9 +120,20 @@ export function CredentialsPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await credentials.remove(id);
|
||||
setItems((prev) => prev.filter((c) => c.id !== id));
|
||||
const requestDelete = (id: string) => {
|
||||
setDeleteId(id);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await credentials.remove(deleteId);
|
||||
setItems((prev) => prev.filter((c) => c.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -159,11 +173,29 @@ export function CredentialsPage() {
|
||||
) : (
|
||||
<div className={styles.envGrid}>
|
||||
{items.map((credential) => (
|
||||
<CredentialCard key={credential.id} credential={credential} onDelete={handleDelete} />
|
||||
<CredentialCard key={credential.id} credential={credential} onDelete={requestDelete} />
|
||||
))}
|
||||
<AddCredentialCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={deleteId != null}
|
||||
title={t('credentials.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('credentials.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this credential?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Upload, Pencil, Trash2, Globe } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Modal, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function EnvironmentDetailPage() {
|
||||
@@ -15,6 +15,8 @@ export function EnvironmentDetailPage() {
|
||||
const [env, setEnv] = useState<Environment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,8 +30,14 @@ export function EnvironmentDetailPage() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!env) return;
|
||||
await environments.remove(env.id);
|
||||
navigate('/environments');
|
||||
setDeleting(true);
|
||||
try {
|
||||
await environments.remove(env.id);
|
||||
navigate('/environments');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -67,7 +75,7 @@ export function EnvironmentDetailPage() {
|
||||
<Pencil size={14} />
|
||||
{t('environments.action_edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleDelete}>
|
||||
<Button variant="danger" size="sm" onClick={() => setConfirmDeleteOpen(true)}>
|
||||
<Trash2 size={14} />
|
||||
{t('environments.action_delete')}
|
||||
</Button>
|
||||
@@ -118,6 +126,24 @@ export function EnvironmentDetailPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={confirmDeleteOpen}
|
||||
title={t('environments.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('environments.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this environment?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Card,
|
||||
ContextMenu,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -120,6 +121,8 @@ export function EnvironmentsPage() {
|
||||
const [items, setItems] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => {
|
||||
@@ -134,9 +137,20 @@ export function EnvironmentsPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await environments.remove(id);
|
||||
setItems((prev) => prev.filter((e) => e.id !== id));
|
||||
const requestDelete = (id: string) => {
|
||||
setDeleteId(id);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await environments.remove(deleteId);
|
||||
setItems((prev) => prev.filter((e) => e.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -176,11 +190,29 @@ export function EnvironmentsPage() {
|
||||
) : (
|
||||
<div className={styles.envGrid}>
|
||||
{items.map((env) => (
|
||||
<EnvironmentCard key={env.id} env={env} onDelete={handleDelete} />
|
||||
<EnvironmentCard key={env.id} env={env} onDelete={requestDelete} />
|
||||
))}
|
||||
<AddEnvironmentCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={deleteId != null}
|
||||
title={t('environments.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('environments.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this environment?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Card,
|
||||
DescriptionList,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Table,
|
||||
Timestamp,
|
||||
@@ -41,6 +42,10 @@ export function ScenarioDetailPage() {
|
||||
const [draggedStepId, setDraggedStepId] = useState<string | null>(null);
|
||||
const [dragOverStepId, setDragOverStepId] = useState<string | null>(null);
|
||||
const [reorderingSteps, setReorderingSteps] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<
|
||||
{ type: 'scenario' } | { type: 'step'; id: string } | { type: 'credential'; id: string } | null
|
||||
>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -148,6 +153,26 @@ export function ScenarioDetailPage() {
|
||||
setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId));
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
if (pendingDelete.type === 'scenario') {
|
||||
await handleDelete();
|
||||
return;
|
||||
}
|
||||
if (pendingDelete.type === 'step') {
|
||||
await handleDeleteStep(pendingDelete.id);
|
||||
}
|
||||
if (pendingDelete.type === 'credential') {
|
||||
await handleRemoveCredential(pendingDelete.id);
|
||||
}
|
||||
setPendingDelete(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepColumns: TableColumn<ScenarioStep>[] = [
|
||||
{
|
||||
key: 'drag',
|
||||
@@ -226,7 +251,7 @@ export function ScenarioDetailPage() {
|
||||
title={t('steps.action_delete')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteStep(s.id);
|
||||
setPendingDelete({ type: 'step', id: s.id });
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -261,7 +286,7 @@ export function ScenarioDetailPage() {
|
||||
title={t('scenarios.cred_action_remove')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCredential(sc.id);
|
||||
setPendingDelete({ type: 'credential', id: sc.id });
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -297,7 +322,7 @@ export function ScenarioDetailPage() {
|
||||
<Pencil size={14} />
|
||||
{t('scenarios.action_edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleDelete}>
|
||||
<Button variant="danger" size="sm" onClick={() => setPendingDelete({ type: 'scenario' })}>
|
||||
<Trash2 size={14} />
|
||||
{t('scenarios.action_delete')}
|
||||
</Button>
|
||||
@@ -456,6 +481,32 @@ export function ScenarioDetailPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={pendingDelete != null}
|
||||
title={
|
||||
pendingDelete?.type === 'step'
|
||||
? t('steps.action_delete')
|
||||
: pendingDelete?.type === 'credential'
|
||||
? t('scenarios.cred_action_remove')
|
||||
: t('scenarios.action_delete')
|
||||
}
|
||||
onClose={() => !deleting && setPendingDelete(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPendingDelete(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirmDelete} disabled={deleting}>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{pendingDelete?.type === 'step' && 'Are you sure you want to delete this step?'}
|
||||
{pendingDelete?.type === 'credential' && 'Are you sure you want to remove this credential from scenario?'}
|
||||
{pendingDelete?.type === 'scenario' && 'Are you sure you want to delete this scenario?'}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@ import { Play, Plus, Trash2, Download, ClipboardList } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { scenarios } from '../../api';
|
||||
import type { Scenario } from '../../api';
|
||||
import { Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge, useToast } from '../../ui';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Modal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
useToast,
|
||||
} from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function ScenariosPage() {
|
||||
@@ -15,6 +24,8 @@ export function ScenariosPage() {
|
||||
const [items, setItems] = useState<Scenario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -40,10 +51,17 @@ export function ScenariosPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await scenarios.remove(id);
|
||||
setLoading(true);
|
||||
load();
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await scenarios.remove(deleteId);
|
||||
setLoading(true);
|
||||
load();
|
||||
setDeleteId(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -83,7 +101,10 @@ export function ScenariosPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
title={t('scenarios.action_delete')}
|
||||
onClick={() => handleDelete(s.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(s.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
@@ -124,6 +145,24 @@ export function ScenariosPage() {
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
onRowClick={(s) => navigate(`/scenarios/${s.id}`)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={deleteId != null}
|
||||
title={t('scenarios.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('scenarios.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this scenario?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -22,6 +23,8 @@ export function SessionDetailPage() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -34,8 +37,14 @@ export function SessionDetailPage() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!session) return;
|
||||
await sessions.remove(session.id);
|
||||
navigate('/sessions');
|
||||
setDeleting(true);
|
||||
try {
|
||||
await sessions.remove(session.id);
|
||||
navigate('/sessions');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -48,7 +57,7 @@ export function SessionDetailPage() {
|
||||
]}
|
||||
/>
|
||||
{session && (
|
||||
<Button variant="danger" size="sm" onClick={handleDelete}>
|
||||
<Button variant="danger" size="sm" onClick={() => setConfirmDeleteOpen(true)}>
|
||||
<Trash2 size={14} />
|
||||
{t('sessions.action_delete')}
|
||||
</Button>
|
||||
@@ -92,6 +101,24 @@ export function SessionDetailPage() {
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={confirmDeleteOpen}
|
||||
title={t('sessions.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('sessions.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this session?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Modal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
Timestamp,
|
||||
@@ -21,6 +22,8 @@ export function SessionsPage() {
|
||||
const [items, setItems] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
sessions
|
||||
@@ -34,10 +37,17 @@ export function SessionsPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await sessions.remove(id);
|
||||
setLoading(true);
|
||||
load();
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await sessions.remove(deleteId);
|
||||
setLoading(true);
|
||||
load();
|
||||
setDeleteId(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumn<Session>[] = [
|
||||
@@ -65,7 +75,10 @@ export function SessionsPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
title={t('sessions.action_delete')}
|
||||
onClick={() => handleDelete(s.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(s.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
@@ -88,6 +101,24 @@ export function SessionsPage() {
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={deleteId != null}
|
||||
title={t('sessions.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('sessions.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this session?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Card,
|
||||
DescriptionList,
|
||||
MarkdownContent,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -24,6 +25,8 @@ export function SnippetDetailPage() {
|
||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -36,8 +39,14 @@ export function SnippetDetailPage() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!snippet) return;
|
||||
await snippets.remove(snippet.id);
|
||||
navigate('/snippets');
|
||||
setDeleting(true);
|
||||
try {
|
||||
await snippets.remove(snippet.id);
|
||||
navigate('/snippets');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -75,7 +84,7 @@ export function SnippetDetailPage() {
|
||||
<Pencil size={14} />
|
||||
{t('snippets.action_edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleDelete}>
|
||||
<Button variant="danger" size="sm" onClick={() => setConfirmDeleteOpen(true)}>
|
||||
<Trash2 size={14} />
|
||||
{t('snippets.action_delete')}
|
||||
</Button>
|
||||
@@ -127,6 +136,24 @@ export function SnippetDetailPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={confirmDeleteOpen}
|
||||
title={t('snippets.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('snippets.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this snippet?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@ import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, ContextMenu, MarkdownContent, Timestamp, UuidBadge } from '../../ui';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
ContextMenu,
|
||||
MarkdownContent,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
function firstParagraphBlocks(markdown: string, limit = 2): { preview: string; truncated: boolean } {
|
||||
@@ -105,6 +114,8 @@ export function SnippetsPage() {
|
||||
const [items, setItems] = useState<Snippet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => {
|
||||
@@ -119,9 +130,20 @@ export function SnippetsPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await snippets.remove(id);
|
||||
load();
|
||||
const requestDelete = (id: string) => {
|
||||
setDeleteId(id);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await snippets.remove(deleteId);
|
||||
load();
|
||||
setDeleteId(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -162,12 +184,30 @@ export function SnippetsPage() {
|
||||
{!loading && (
|
||||
<div className={styles.envGrid}>
|
||||
{items.map((s) => (
|
||||
<SnippetCard key={s.id} snippet={s} onDelete={handleDelete} />
|
||||
<SnippetCard key={s.id} snippet={s} onDelete={requestDelete} />
|
||||
))}
|
||||
{items.length === 0 && <p className={styles.muted}>{t('snippets.empty')}</p>}
|
||||
<AddSnippetCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={deleteId != null}
|
||||
title={t('snippets.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('snippets.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this snippet?
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: color-mix(in srgb, #000 45%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(520px, 100%);
|
||||
background-color: var(--color-bg-subtle, #ffffff);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 20px 48px color-mix(in srgb, #000 28%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--space-4) var(--space-5) var(--space-2);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--space-2) var(--space-5) var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: var(--space-3) var(--space-5) var(--space-4);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import styles from './Modal.module.css';
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
onClose: () => void;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, title, children, onClose, footer }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className={styles.dialog}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,9 @@ export type { DescriptionListProps, DescriptionListItem } from './DescriptionLis
|
||||
export { Notification } from './Notification/Notification';
|
||||
export type { NotificationProps, NotificationVariant } from './Notification/Notification';
|
||||
|
||||
export { Modal } from './Modal/Modal';
|
||||
export type { ModalProps } from './Modal/Modal';
|
||||
|
||||
export { Table } from './Table/Table';
|
||||
export type { TableProps, TableColumn } from './Table/Table';
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/MarkdownContent/MarkdownContent.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"}
|
||||
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/MarkdownContent/MarkdownContent.tsx","./src/ui/Modal/Modal.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"}
|
||||
Reference in New Issue
Block a user