Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@tauri-apps/api": "^1.6.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^9.0.1",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
Expand Down
289 changes: 289 additions & 0 deletions src/components/PostToElogDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import { useState, useEffect, useMemo } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
OutlinedInput,
Select,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from '@mui/material';
import ReactMarkdown from 'react-markdown';

import { PV, Snapshot } from '../types';
import { elogService } from '../services/elogService';
import { ElogTag, ElogLogbook, ElogConfigDTO } from '../types/elog';
import { buildSnapshotElogBody } from '../utils/elogBody';

interface PostToElogDialogProps {
open: boolean;
snapshot: Snapshot;
selectedPVs: PV[];
config: ElogConfigDTO;
onClose: () => void;
onPosted?: (entryId: string) => void;
}

export function PostToElogDialog({
open,
snapshot,
selectedPVs,
config,
onClose,
onPosted,
}: PostToElogDialogProps) {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [logbooks, setLogbooks] = useState<ElogLogbook[]>([]);
const [selectedLogbookIds, setSelectedLogbookIds] = useState<string[]>([]);
const [tags, setTags] = useState<ElogTag[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [tab, setTab] = useState<'edit' | 'preview'>('edit');
const [loadingMeta, setLoadingMeta] = useState(false);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);

// Reset form state every time the dialog opens.
useEffect(() => {
if (!open) return;
setTitle(`Snapshot: ${snapshot.title}`);
setBody(buildSnapshotElogBody(snapshot, selectedPVs));
setSelectedLogbookIds(config.defaultLogbooks ?? []);
setSelectedTagIds([]);
setTab('edit');
setError(null);
}, [open, snapshot, selectedPVs, config.defaultLogbooks]);

// Fetch logbooks once per open.
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoadingMeta(true);
elogService
.listLogbooks()
.then((items) => {
if (cancelled) return;
setLogbooks(items);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Failed to load logbooks');
})
.finally(() => {
if (!cancelled) setLoadingMeta(false);
});
return () => {
cancelled = true;
};
}, [open]);

// Re-fetch tags whenever the first selected logbook changes.
useEffect(() => {
if (!open || selectedLogbookIds.length === 0) {
setTags([]);
return undefined;
}
const [primaryLogbook] = selectedLogbookIds;
let cancelled = false;
elogService
.listTags(primaryLogbook)
.then((items) => {
if (!cancelled) setTags(items);
})
.catch(() => {
if (!cancelled) setTags([]);
});
return () => {
cancelled = true;
};
}, [open, selectedLogbookIds]);

const logbookLookup = useMemo(() => {
const map: Record<string, string> = {};
logbooks.forEach((lb) => {
map[lb.id] = lb.name;
});
return map;
}, [logbooks]);

const tagLookup = useMemo(() => {
const map: Record<string, string> = {};
tags.forEach((t) => {
map[t.id] = t.name;
});
return map;
}, [tags]);

const canPost =
!posting && title.trim().length > 0 && body.length > 0 && selectedLogbookIds.length > 0;

const handlePost = async () => {
setPosting(true);
setError(null);
try {
const result = await elogService.createEntry({
logbooks: selectedLogbookIds,
title: title.trim(),
bodyMarkdown: body,
tags: selectedTagIds,
snapshotId: snapshot.uuid,
});
onPosted?.(result.id);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to post entry');
} finally {
setPosting(false);
}
};

return (
<Dialog open={open} onClose={posting ? undefined : onClose} maxWidth="md" fullWidth>
<DialogTitle>Post to Elog</DialogTitle>
<DialogContent dividers>
<Stack spacing={2} sx={{ mt: 0.5 }}>
{error && <Alert severity="error">{error}</Alert>}

<FormControl size="small" fullWidth required>
<InputLabel>Logbooks</InputLabel>
<Select
multiple
value={selectedLogbookIds}
onChange={(e) => {
const val = e.target.value;
setSelectedLogbookIds(typeof val === 'string' ? val.split(',') : val);
}}
input={<OutlinedInput label="Logbooks" />}
disabled={loadingMeta}
renderValue={(ids) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{ids.map((id) => (
<Chip key={id} label={logbookLookup[id] || id} size="small" />
))}
</Box>
)}
>
{logbooks.map((lb) => (
<MenuItem key={lb.id} value={lb.id}>
{lb.name}
</MenuItem>
))}
</Select>
</FormControl>

{tags.length > 0 && (
<FormControl size="small" fullWidth>
<InputLabel>Tags</InputLabel>
<Select
multiple
value={selectedTagIds}
onChange={(e) => {
const val = e.target.value;
setSelectedTagIds(typeof val === 'string' ? val.split(',') : val);
}}
input={<OutlinedInput label="Tags" />}
renderValue={(ids) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{ids.map((id) => (
<Chip key={id} label={tagLookup[id] || id} size="small" />
))}
</Box>
)}
>
{tags.map((t) => (
<MenuItem key={t.id} value={t.id}>
{t.name}
</MenuItem>
))}
</Select>
</FormControl>
)}

<TextField
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
size="small"
required
inputProps={{ maxLength: 255 }}
/>

<Box>
<Tabs
value={tab}
onChange={(_e, v: 'edit' | 'preview') => setTab(v)}
sx={{ minHeight: 36, mb: 1 }}
>
<Tab value="edit" label="Edit" sx={{ minHeight: 36, py: 0.5 }} />
<Tab value="preview" label="Preview" sx={{ minHeight: 36, py: 0.5 }} />
</Tabs>
{tab === 'edit' ? (
<TextField
value={body}
onChange={(e) => setBody(e.target.value)}
fullWidth
multiline
rows={16}
size="small"
placeholder="Write the entry body in markdown..."
InputProps={{ sx: { fontFamily: 'monospace', fontSize: 13 } }}
/>
) : (
<Box
sx={{
minHeight: 360,
maxHeight: 480,
overflow: 'auto',
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 2,
'& table': { borderCollapse: 'collapse' },
'& th, & td': {
border: 1,
borderColor: 'divider',
px: 1,
py: 0.5,
fontSize: 13,
},
}}
>
<ReactMarkdown>{body}</ReactMarkdown>
</Box>
)}
</Box>

<Typography variant="caption" color="text.secondary">
Posting as the API key owner. Your name will be recorded as the entry author.
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={posting}>
Cancel
</Button>
<Button
onClick={handlePost}
variant="contained"
disabled={!canPost}
startIcon={posting ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{posting ? 'Posting...' : 'Post'}
</Button>
</DialogActions>
</Dialog>
);
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './CreateSnapshotDialog';
export * from './VirtualTable';
export * from './LiveDataWarningBanner';
export * from './TagGroupSelect';
export * from './PostToElogDialog';
9 changes: 0 additions & 9 deletions src/config/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,6 @@ export function getWebSocketURL(path: string): string {
return `${protocol}//${window.location.host}${path}`;
}

/**
* Generic API response wrapper from squirrel-backend
*/
export interface ApiResultResponse<T> {
errorCode: number;
errorMessage: string | null;
payload: T;
}

/**
* Paged result wrapper for paginated endpoints
*/
Expand Down
62 changes: 29 additions & 33 deletions src/contexts/LivePVContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,40 +64,36 @@ export function LivePVProvider({
throw new Error(`HTTP ${response.status}`);
}

const data = await response.json();

if (data.errorCode === 0 && data.payload) {
const rawValues = data.payload as Record<
string,
{
value: unknown;
connected: boolean;
updated_at: number;
status?: string;
severity?: number;
units?: string;
}
>;

// Transform backend format to EpicsData format
setLiveValues((prev) => {
const next = new Map(prev);
Object.entries(rawValues).forEach(([pvName, rawValue]) => {
// Map backend fields to EpicsData fields
const epicsData: EpicsData = {
data: rawValue.value as EpicsData['data'],
severity: rawValue.severity,
units: rawValue.units,
timestamp: rawValue.updated_at ? new Date(rawValue.updated_at * 1000) : undefined,
};
next.set(pvName, epicsData);
});
return next;
const rawValues = (await response.json()) as Record<
string,
{
value: unknown;
connected: boolean;
updated_at: number;
status?: string;
severity?: number;
units?: string;
}
>;

// Transform backend format to EpicsData format
setLiveValues((prev) => {
const next = new Map(prev);
Object.entries(rawValues).forEach(([pvName, rawValue]) => {
// Map backend fields to EpicsData fields
const epicsData: EpicsData = {
data: rawValue.value as EpicsData['data'],
severity: rawValue.severity,
units: rawValue.units,
timestamp: rawValue.updated_at ? new Date(rawValue.updated_at * 1000) : undefined,
};
next.set(pvName, epicsData);
});
setLastUpdate(new Date());
setIsConnected(true);
setError(null);
}
return next;
});
setLastUpdate(new Date());
setIsConnected(true);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch live values');
setIsConnected(false);
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ export {
type PVUpdate,
} from './useBufferedLiveData';

export { useElogConfig } from './useElogConfig';

// Re-export query hooks
export * from './queries';
Loading
Loading