refactor(decnet_web/CanaryTokens): move UploadModal out
Verbatim move of the artifact upload modal (~130 LOC) into its own file. Drop-or-browse picker, server-side-injection warning banner, and the multipart POST stay unchanged. - New CanaryTokens/UploadModal.tsx - UploadModal.test.tsx covers title rendering, empty drop-zone hint, server-injection warning banner, UPLOAD-disabled-until- file, and CANCEL -> onClose.
This commit is contained in:
@@ -14,135 +14,7 @@ import {
|
||||
import { extractError, fmt, fmtBytes } from './CanaryTokens/helpers';
|
||||
import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui';
|
||||
import { CreateTokenModal } from './CanaryTokens/CreateTokenModal';
|
||||
|
||||
// ─── BLOB UPLOAD MODAL ─────────────────────────────────────────────────────
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
onUploaded: (blob: BlobRow) => void;
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({ onClose, onUploaded }) => {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscapeKey(onClose, true);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return setError('Pick a file first.');
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api.post('/canary/blobs', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
onUploaded(res.data);
|
||||
} catch (err) {
|
||||
setError(extractError(err, 'Upload failed.'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
width: 'min(520px, 100%)',
|
||||
backgroundColor: 'var(--bg-color, #0d1117)',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
padding: '24px', color: 'var(--text-color)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>UPLOAD CANARY ARTIFACT</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) setFile(f);
|
||||
}}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`,
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
cursor: 'pointer',
|
||||
background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent',
|
||||
}}
|
||||
onClick={() => document.getElementById('canary-blob-input')?.click()}
|
||||
>
|
||||
<Upload size={32} style={{ opacity: 0.5, marginBottom: '8px' }} />
|
||||
<div style={{ fontSize: '0.85rem' }}>
|
||||
{file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'}
|
||||
</div>
|
||||
{!file && (
|
||||
<div style={{ fontSize: '0.7rem', opacity: 0.6, marginTop: '6px' }}>
|
||||
DOCX · XLSX · PDF · HTML · PNG/JPEG · plain configs
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="canary-blob-input"
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 12px', marginBottom: '16px',
|
||||
border: '1px solid var(--warn)',
|
||||
backgroundColor: 'var(--warn-tint-10)',
|
||||
fontSize: '0.75rem', color: 'var(--warn)',
|
||||
}}>
|
||||
<AlertTriangle size={14} />
|
||||
DECNET injects the callback server-side; the original bytes stay on the master.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || uploading}
|
||||
style={{ ...BTN_PRIMARY, opacity: (!file || uploading) ? 0.5 : 1, cursor: uploading ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{uploading ? 'UPLOADING…' : 'UPLOAD'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { UploadModal } from './CanaryTokens/UploadModal';
|
||||
|
||||
// ─── FILE DROP MODAL ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
35
decnet_web/src/components/CanaryTokens/UploadModal.test.tsx
Normal file
35
decnet_web/src/components/CanaryTokens/UploadModal.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UploadModal } from './UploadModal';
|
||||
|
||||
vi.mock('../../hooks/useFocusTrap', () => ({ useFocusTrap: () => {} }));
|
||||
|
||||
describe('UploadModal', () => {
|
||||
it('renders the title and the empty drop zone hint', () => {
|
||||
render(<UploadModal onClose={() => {}} onUploaded={() => {}} />);
|
||||
expect(screen.getByText('UPLOAD CANARY ARTIFACT')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drop a file here or click to browse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the operator-warning banner about server-side injection', () => {
|
||||
render(<UploadModal onClose={() => {}} onUploaded={() => {}} />);
|
||||
expect(
|
||||
screen.getByText(/DECNET injects the callback server-side/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('UPLOAD button stays disabled until a file is picked', () => {
|
||||
render(<UploadModal onClose={() => {}} onUploaded={() => {}} />);
|
||||
const upload = screen.getByText('UPLOAD') as HTMLButtonElement;
|
||||
expect(upload.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('CANCEL invokes onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<UploadModal onClose={onClose} onUploaded={() => {}} />);
|
||||
await user.click(screen.getByText('CANCEL'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
139
decnet_web/src/components/CanaryTokens/UploadModal.tsx
Normal file
139
decnet_web/src/components/CanaryTokens/UploadModal.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, X, AlertTriangle } from '../../icons';
|
||||
import api from '../../utils/api';
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
import type { BlobRow } from './types';
|
||||
import { extractError, fmtBytes } from './helpers';
|
||||
import { BTN_PRIMARY, BTN_GHOST } from './ui';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onUploaded: (blob: BlobRow) => void;
|
||||
}
|
||||
|
||||
/** Drop-or-browse upload of an operator-supplied artifact. The
|
||||
* server keeps the original bytes on the master and injects the
|
||||
* callback marker downstream — the warning banner repeats that to
|
||||
* the operator before they hand over a sensitive document. */
|
||||
export const UploadModal: React.FC<Props> = ({ onClose, onUploaded }) => {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscapeKey(onClose, true);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return setError('Pick a file first.');
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api.post('/canary/blobs', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
onUploaded(res.data);
|
||||
} catch (err) {
|
||||
setError(extractError(err, 'Upload failed.'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
width: 'min(520px, 100%)',
|
||||
backgroundColor: 'var(--bg-color, #0d1117)',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
padding: '24px', color: 'var(--text-color)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>UPLOAD CANARY ARTIFACT</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) setFile(f);
|
||||
}}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`,
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
cursor: 'pointer',
|
||||
background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent',
|
||||
}}
|
||||
onClick={() => document.getElementById('canary-blob-input')?.click()}
|
||||
>
|
||||
<Upload size={32} style={{ opacity: 0.5, marginBottom: '8px' }} />
|
||||
<div style={{ fontSize: '0.85rem' }}>
|
||||
{file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'}
|
||||
</div>
|
||||
{!file && (
|
||||
<div style={{ fontSize: '0.7rem', opacity: 0.6, marginTop: '6px' }}>
|
||||
DOCX · XLSX · PDF · HTML · PNG/JPEG · plain configs
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="canary-blob-input"
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 12px', marginBottom: '16px',
|
||||
border: '1px solid var(--warn)',
|
||||
backgroundColor: 'var(--warn-tint-10)',
|
||||
fontSize: '0.75rem', color: 'var(--warn)',
|
||||
}}>
|
||||
<AlertTriangle size={14} />
|
||||
DECNET injects the callback server-side; the original bytes stay on the master.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || uploading}
|
||||
style={{ ...BTN_PRIMARY, opacity: (!file || uploading) ? 0.5 : 1, cursor: uploading ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{uploading ? 'UPLOADING…' : 'UPLOAD'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user