Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ interface CommitRequest {
apiKey: string;
}

/**
* Request body interface for publish endpoint
*/
interface PublishRequest {
dandisetId: string;
apiKey: string;
}

/**
* Handle CORS preflight requests
*/
Expand Down Expand Up @@ -194,6 +202,107 @@ async function handleCommit(request: Request): Promise<Response> {
}
}

/**
* Handle publish dandiset request
*/
async function handlePublish(request: Request): Promise<Response> {
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
*/
Expand All @@ -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(
Expand Down
45 changes: 38 additions & 7 deletions src/components/Controls/CommitButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string | null>(null);
const [commitSuccess, setCommitSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState<string>('Metadata committed successfully!');
const [copySuccess, setCopySuccess] = useState(false);
const [copyError, setCopyError] = useState<string | null>(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;
}

Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -172,7 +196,7 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) {
color="success"
size="small"
startIcon={isCommitting ? <CircularProgress size={16} color="inherit" /> : (!apiKey ? <LockIcon /> : <SaveIcon />)}
onClick={handleCommit}
onClick={handleCommitClick}
disabled={!canCommit || isCommitting}
>
{isCommitting ? 'Committing...' : 'Commit Changes'}
Expand All @@ -188,13 +212,13 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) {
onClose={() => setCommitSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setCommitSuccess(false)}
severity="success"
<Alert
onClose={() => setCommitSuccess(false)}
severity="success"
variant="filled"
sx={{ width: '100%' }}
>
Metadata committed successfully!
{successMessage}
</Alert>
</Snackbar>

Expand Down Expand Up @@ -248,6 +272,13 @@ export function CommitButton({ isReviewMode = false }: CommitButtonProps) {
{copyError}
</Alert>
</Snackbar>

{/* Commit confirmation dialog */}
<CommitConfirmDialog
open={showDialog}
onClose={handleDialogClose}
isProcessing={isCommitting}
/>
</>
);
}
61 changes: 61 additions & 0 deletions src/components/Controls/CommitConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog
open={open}
onClose={() => !isProcessing && onClose('cancel')}
aria-labelledby="commit-dialog-title"
maxWidth="sm"
fullWidth
>
<DialogTitle id="commit-dialog-title">
Commit Changes
</DialogTitle>
<DialogContent>
<DialogContentText>
Choose how to proceed with your metadata changes:
</DialogContentText>
<DialogContentText sx={{ mt: 2 }}>
<strong>Commit and Publish (Recommended):</strong> Save your changes and immediately publish a new version of your dandiset.
</DialogContentText>
<DialogContentText sx={{ mt: 1 }}>
<strong>Commit Only:</strong> Save your changes to the draft without publishing.
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => onClose('cancel')}
disabled={isProcessing}
>
Cancel
</Button>
<Button
onClick={() => onClose('commit-only')}
disabled={isProcessing}
variant="outlined"
color="primary"
>
Commit Only
</Button>
<Button
onClick={() => onClose('commit-and-publish')}
disabled={isProcessing}
variant="contained"
color="success"
autoFocus
>
Commit and Publish
</Button>
</DialogActions>
</Dialog>
);
}
35 changes: 35 additions & 0 deletions src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
}