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 { extractError, fmt, fmtBytes } from './CanaryTokens/helpers';
|
||||||
import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui';
|
import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field, Stat } from './CanaryTokens/ui';
|
||||||
import { CreateTokenModal } from './CanaryTokens/CreateTokenModal';
|
import { CreateTokenModal } from './CanaryTokens/CreateTokenModal';
|
||||||
|
import { UploadModal } from './CanaryTokens/UploadModal';
|
||||||
// ─── 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── FILE DROP MODAL ───────────────────────────────────────────────────────
|
// ─── 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