diff --git a/proxy/src/index.ts b/proxy/src/index.ts index 86bb84d..a9ff5b8 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -19,6 +19,14 @@ interface CommitRequest { apiKey: string; } +/** + * Request body interface for publish endpoint + */ +interface PublishRequest { + dandisetId: string; + apiKey: string; +} + /** * Handle CORS preflight requests */ @@ -194,6 +202,107 @@ async function handleCommit(request: Request): Promise { } } +/** + * Handle publish dandiset request + */ +async function handlePublish(request: Request): Promise { + const origin = request.headers.get('Origin'); + + // Validate origin + if (!origin || !ALLOWED_ORIGINS.includes(origin)) { + return new Response( + JSON.stringify({ error: 'Origin not allowed' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ); + } + + const corsHeaders = getCorsHeaders(origin); + + try { + // Parse request body + const body: PublishRequest = await request.json(); + const { dandisetId, apiKey } = body; + + // Validate required fields + if (!dandisetId || !apiKey) { + return new Response( + JSON.stringify({ + error: 'Missing required fields: dandisetId, apiKey' + }), + { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + } + ); + } + + // Forward to DANDI API publish endpoint + const dandiUrl = `${DANDI_API_BASE}/dandisets/${dandisetId}/versions/draft/publish/`; + + const dandiResponse = await fetch(dandiUrl, { + method: 'POST', + headers: { + 'Authorization': `token ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + // Get response body + const responseText = await dandiResponse.text(); + let responseData; + try { + responseData = JSON.parse(responseText); + } catch { + responseData = { message: responseText }; + } + + // Check if DANDI request was successful + if (!dandiResponse.ok) { + return new Response( + JSON.stringify({ + error: 'DANDI API publish request failed', + status: dandiResponse.status, + message: responseData.message || responseData.detail || responseText, + details: responseData, + }), + { + status: dandiResponse.status, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + } + ); + } + + console.log(`Successfully published dandiset ${dandisetId}`); + + // Return success response + return new Response( + JSON.stringify({ + success: true, + message: 'Dandiset published successfully', + data: responseData, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + } + ); + + } catch (error) { + console.error('Error handling publish request:', error); + + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + } + ); + } +} + /** * Main Worker export */ @@ -212,6 +321,11 @@ export default { return handleCommit(request); } + // Route to publish endpoint + if (url.pathname === '/publish' && request.method === 'POST') { + return handlePublish(request); + } + // Health check endpoint if (url.pathname === '/' || url.pathname === '/health') { return new Response( diff --git a/src/components/Controls/CommitButton.tsx b/src/components/Controls/CommitButton.tsx index a2c426e..9899fe6 100644 --- a/src/components/Controls/CommitButton.tsx +++ b/src/components/Controls/CommitButton.tsx @@ -5,9 +5,10 @@ import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import LockIcon from '@mui/icons-material/Lock'; import LinkIcon from '@mui/icons-material/Link'; import { useMetadataContext } from '../../context/MetadataContext'; -import { commitMetadataChanges, fetchDandisetVersionInfo } from '../../utils/api'; +import { commitMetadataChanges, fetchDandisetVersionInfo, publishDandiset } from '../../utils/api'; import { createProposalLink } from '../../core/proposalLink'; import type { DandisetMetadata } from '../../types/dandiset'; +import { CommitConfirmDialog, type CommitAction } from './CommitConfirmDialog'; interface CommitButtonProps { isReviewMode?: boolean; @@ -26,17 +27,30 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) { clearModifications } = useMetadataContext(); + const [showDialog, setShowDialog] = useState(false); const [isCommitting, setIsCommitting] = useState(false); const [commitError, setCommitError] = useState(null); const [commitSuccess, setCommitSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState('Metadata committed successfully!'); const [copySuccess, setCopySuccess] = useState(false); const [copyError, setCopyError] = useState(null); const hasChanges = JSON.stringify(originalMetadata) !== JSON.stringify(modifiedMetadata); const canCommit = hasChanges && !!apiKey && !!versionInfo; - const handleCommit = async () => { + const handleCommitClick = () => { + if (!canCommit) return; + setShowDialog(true); + }; + + const handleDialogClose = async (action: CommitAction) => { + if (action === 'cancel') { + setShowDialog(false); + return; + } + if (!apiKey || !versionInfo || !dandisetId || !version) { + setShowDialog(false); return; } @@ -47,9 +61,18 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) { // Commit the changes via the proxy await commitMetadataChanges(dandisetId, version, modifiedMetadata, apiKey); + // If commit and publish, also publish + if (action === 'commit-and-publish') { + await publishDandiset(dandisetId, apiKey); + setSuccessMessage('Metadata committed and dandiset published successfully!'); + } else { + setSuccessMessage('Metadata committed successfully!'); + } + // Success! Clear pending changes clearModifications(); setCommitSuccess(true); + setShowDialog(false); // Refresh the version info to get the latest state setIsLoading(true); @@ -66,6 +89,7 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) { } catch (error) { console.error('Commit failed:', error); setCommitError(error instanceof Error ? error.message : 'Failed to commit changes'); + setShowDialog(false); } finally { setIsCommitting(false); } @@ -172,7 +196,7 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) { color="success" size="small" startIcon={isCommitting ? : (!apiKey ? : )} - onClick={handleCommit} + onClick={handleCommitClick} disabled={!canCommit || isCommitting} > {isCommitting ? 'Committing...' : 'Commit Changes'} @@ -188,13 +212,13 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) { onClose={() => setCommitSuccess(false)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - setCommitSuccess(false)} - severity="success" + setCommitSuccess(false)} + severity="success" variant="filled" sx={{ width: '100%' }} > - Metadata committed successfully! + {successMessage} @@ -248,6 +272,13 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) { {copyError} + + {/* Commit confirmation dialog */} + ); } diff --git a/src/components/Controls/CommitConfirmDialog.tsx b/src/components/Controls/CommitConfirmDialog.tsx new file mode 100644 index 0000000..a0ea892 --- /dev/null +++ b/src/components/Controls/CommitConfirmDialog.tsx @@ -0,0 +1,61 @@ +import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material'; + +export type CommitAction = 'cancel' | 'commit-only' | 'commit-and-publish'; + +interface CommitConfirmDialogProps { + open: boolean; + onClose: (action: CommitAction) => void; + isProcessing?: boolean; +} + +export function CommitConfirmDialog({ open, onClose, isProcessing = false }: CommitConfirmDialogProps) { + return ( + !isProcessing && onClose('cancel')} + aria-labelledby="commit-dialog-title" + maxWidth="sm" + fullWidth + > + + Commit Changes + + + + Choose how to proceed with your metadata changes: + + + Commit and Publish (Recommended): Save your changes and immediately publish a new version of your dandiset. + + + Commit Only: Save your changes to the draft without publishing. + + + + + + + + + ); +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 9ef4d53..0d861e9 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -110,3 +110,38 @@ export async function commitMetadataChanges( const data = await response.json(); console.log('Metadata committed successfully', data); } + +export async function publishDandiset( + dandisetId: string, + apiKey: string +): Promise { + const url = `${PROXY_URL}/publish`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + dandisetId, + apiKey, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + + if (response.status === 401 || response.status === 403) { + throw new Error('Authentication failed. Please check your API key.'); + } + + throw new Error( + errorData.message || + errorData.error || + `Failed to publish dandiset: ${response.statusText}` + ); + } + + const data = await response.json(); + console.log('Dandiset published successfully', data); +}