FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
PropertyInspector.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import LibraryAddIcon from '@mui/icons-material/LibraryAdd';
5import {
6 Box,
7 Button,
8 Dialog,
9 DialogActions,
10 DialogContent,
11 DialogContentText,
12 DialogTitle,
13 Divider,
14 TextField,
15 Typography,
16} from '@mui/material';
17import { useState } from 'react';
18import { useAssetLibraryStore } from '@/stores/assetLibrary';
19import {
20 findComponentInStore,
21 findItemInStore,
22 useScenarioStore,
23} from '@/stores/scenarioStore';
24import { assertNever } from '@/utils/typeUtils';
25import { AntennaInspector } from './inspectors/AntennaInspector';
26import { GlobalParametersInspector } from './inspectors/GlobalParametersInspector';
27import { PlatformComponentInspector } from './inspectors/PlatformComponentInspector';
28import { PlatformInspector } from './inspectors/PlatformInspector';
29import { TimingInspector } from './inspectors/TimingInspector';
30import { WaveformInspector } from './inspectors/WaveformInspector';
31
32function normalizeAssetName(name: string): string {
33 return name.trim().toLocaleLowerCase();
34}
35
36function SaveToAssetLibraryButton({
37 itemId,
38 itemName,
39}: {
40 itemId: string;
41 itemName: string;
42}) {
43 const [isSaving, setIsSaving] = useState(false);
44 const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
45 const [proposedName, setProposedName] = useState(itemName);
46 const templates = useAssetLibraryStore((state) => state.templates);
47 const hasLoaded = useAssetLibraryStore((state) => state.hasLoaded);
48 const loadCatalog = useAssetLibraryStore((state) => state.loadCatalog);
49 const saveScenarioItem = useAssetLibraryStore(
50 (state) => state.saveScenarioItem
51 );
52 const showSuccess = useScenarioStore((state) => state.showSuccess);
53 const showError = useScenarioStore((state) => state.showError);
54
55 const handleSaveTemplate = async (nameOverride?: string) => {
56 setIsSaving(true);
57 try {
58 const template = await saveScenarioItem(itemId, nameOverride);
59 if (!template) {
60 showError(
61 'Selected item cannot be saved as an asset template.'
62 );
63 return;
64 }
65 showSuccess(`${template.name} saved to Asset Library.`);
66 } catch (error) {
67 const message =
68 error instanceof Error ? error.message : String(error);
69 showError(`Save to Asset Library failed: ${message}`);
70 } finally {
71 setIsSaving(false);
72 }
73 };
74
75 const handleSaveClick = async () => {
76 if (!hasLoaded) {
77 await loadCatalog();
78 }
79
80 const currentTemplates = useAssetLibraryStore.getState().templates;
81 const duplicateExists = currentTemplates.some(
82 (template) =>
83 normalizeAssetName(template.name) ===
84 normalizeAssetName(itemName)
85 );
86
87 if (duplicateExists) {
88 setProposedName(itemName);
89 setDuplicateDialogOpen(true);
90 return;
91 }
92
93 await handleSaveTemplate();
94 };
95
96 const handleKeepDuplicateName = async () => {
97 setDuplicateDialogOpen(false);
98 await handleSaveTemplate(itemName);
99 };
100
101 const handleSaveWithNewName = async () => {
102 const trimmedName = proposedName.trim();
103 if (!trimmedName) {
104 return;
105 }
106
107 setDuplicateDialogOpen(false);
108 await handleSaveTemplate(trimmedName);
109 };
110
111 const normalizedItemName = normalizeAssetName(itemName);
112 const normalizedProposedName = normalizeAssetName(proposedName);
113 const proposedNameConflicts =
114 normalizedProposedName.length > 0 &&
115 templates.some(
116 (template) =>
117 normalizeAssetName(template.name) === normalizedProposedName
118 );
119 const canSaveWithNewName =
120 normalizedProposedName.length > 0 &&
121 normalizedProposedName !== normalizedItemName &&
122 !proposedNameConflicts;
123
124 return (
125 <>
126 <Button
127 size="small"
128 variant="outlined"
129 startIcon={<LibraryAddIcon />}
130 onClick={() => void handleSaveClick()}
131 disabled={isSaving}
132 sx={{ alignSelf: 'flex-start' }}
133 >
134 Save to Asset Library
135 </Button>
136 <Dialog
137 open={isDuplicateDialogOpen}
138 onClose={() => setDuplicateDialogOpen(false)}
139 >
140 <DialogTitle>Asset Name Already Exists</DialogTitle>
141 <DialogContent>
142 <DialogContentText sx={{ mb: 2 }}>
143 Another saved asset already uses this name. Enter a new
144 name, or keep the current name.
145 </DialogContentText>
146 <TextField
147 autoFocus
148 fullWidth
149 label="Asset name"
150 value={proposedName}
151 onChange={(event) =>
152 setProposedName(event.target.value)
153 }
154 error={
155 proposedNameConflicts &&
156 normalizedProposedName !== normalizedItemName
157 }
158 helperText={
159 proposedNameConflicts &&
160 normalizedProposedName !== normalizedItemName
161 ? 'This name is already in use.'
162 : 'Choose a unique name for the saved asset.'
163 }
164 />
165 </DialogContent>
166 <DialogActions>
167 <Button onClick={() => setDuplicateDialogOpen(false)}>
168 Cancel
169 </Button>
170 <Button onClick={() => void handleKeepDuplicateName()}>
171 Keep Name
172 </Button>
173 <Button
174 variant="contained"
175 onClick={() => void handleSaveWithNewName()}
176 disabled={!canSaveWithNewName}
177 >
178 Save New Name
179 </Button>
180 </DialogActions>
181 </Dialog>
182 </>
183 );
184}
185
186function InspectorContent() {
187 const selectedItemId = useScenarioStore((state) => state.selectedItemId);
188 const selectedComponentId = useScenarioStore(
189 (state) => state.selectedComponentId
190 );
191 const selectedItem = useScenarioStore((state) =>
192 findItemInStore(state, selectedItemId)
193 );
194 const selectedComponent = useScenarioStore(
195 (state) =>
196 findComponentInStore(state, selectedComponentId)?.component ?? null
197 );
198
199 if (!selectedItem) {
200 return (
201 <Typography color="text.secondary">
202 Select an item to see its properties.
203 </Typography>
204 );
205 }
206
207 const renderInspector = () => {
208 if (selectedComponent && selectedItem.type === 'Platform') {
209 const componentIndex = selectedItem.components.findIndex(
210 (c) => c.id === selectedComponent.id
211 );
212 if (componentIndex === -1) {
213 return (
214 <Typography color="text.secondary">
215 Select an item to see its properties.
216 </Typography>
217 );
218 }
219 return (
220 <PlatformComponentInspector
221 component={selectedComponent}
222 platformId={selectedItem.id}
223 index={componentIndex}
224 />
225 );
226 }
227 switch (selectedItem.type) {
228 case 'GlobalParameters':
229 return <GlobalParametersInspector item={selectedItem} />;
230 case 'Waveform':
231 return <WaveformInspector item={selectedItem} />;
232 case 'Timing':
233 return <TimingInspector item={selectedItem} />;
234 case 'Antenna':
235 return <AntennaInspector item={selectedItem} />;
236 case 'Platform':
237 return (
238 <PlatformInspector
239 item={selectedItem}
240 selectedComponentId={selectedComponentId}
241 />
242 );
243 default:
244 return assertNever(selectedItem);
245 }
246 };
247
248 const canSaveToAssetLibrary =
249 selectedItem.type !== 'GlobalParameters' && !selectedComponent;
250
251 return (
252 <Box>
253 <Typography variant="overline" color="text.secondary">
254 {selectedItem.type}
255 </Typography>
256 <Divider sx={{ my: 1 }} />
257 {canSaveToAssetLibrary && 'name' in selectedItem && (
258 <Box sx={{ mb: 2 }}>
259 <SaveToAssetLibraryButton
260 itemId={selectedItem.id}
261 itemName={selectedItem.name}
262 />
263 </Box>
264 )}
265 {renderInspector()}
266 </Box>
267 );
268}
269
270export default function PropertyInspector() {
271 return (
272 <Box
273 sx={{
274 height: '100%',
275 display: 'flex',
276 flexDirection: 'column',
277 }}
278 >
279 <Box sx={{ flexShrink: 0, px: 2, pt: 2, pb: 1 }}>
280 <Typography variant="h6">Properties</Typography>
281 </Box>
282 <Divider sx={{ mx: 2 }} />
283
284 <Box
285 sx={{
286 flexGrow: 1,
287 overflowY: 'auto',
288 minHeight: 0,
289 p: 1.5,
290 }}
291 >
292 <InspectorContent />
293 </Box>
294 </Box>
295 );
296}