From 59da040e30659a065918ca1e066caa23ee46bb1e Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Thu, 18 Jun 2026 22:04:55 +0200 Subject: [PATCH] frontend: shared SettingsPanel + typed upload with error display Generalize the expert-panel boilerplate (container + title + widgets + upload button) and surface real upload errors via hey-api. - SettingsPanel: consistent padded Paper + Typography title (one place to size it) + optional Upload button + result snackbar; PanelTitle and UploadSnackbarView exported for panels with bespoke layouts (DetectorSettings grid, ROI) - useUpload(mutation): wraps a generated hey-api mutation, shows the server's error message on failure (errorMessage handles 400 string / 500 error_message) - "unsaved changes" dot on panel titles, derived from the existing last-downloaded snapshot (dirty = !isEqual(value, lastDownloaded)) - fix PixelMask: TIFF upload was swallowing errors; now shows them - config panels (FileWriter, ImageFormat, Detector, AzInt, DarkMask, Indexing, Instrument, ZeroMQ, ROI, DetectorSelection) upload through the typed SDK instead of ButtonWithSnackbar's raw fetch; status/display panels share the same shell for consistent spacing - ButtonWithSnackbar now only powers bodyless action buttons (pedestal/cancel/ initialize, start/trigger, deactivate) Build (tsc + vite) passes; dev server transforms all modules. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/AzIntSettings.tsx | 40 +++----- frontend/src/components/DarkMaskSettings.tsx | 32 ++----- .../src/components/DataProcessingSettings.tsx | 9 +- frontend/src/components/DetectorSelection.tsx | 35 +++---- frontend/src/components/DetectorSettings.tsx | 38 ++++---- frontend/src/components/DetectorStatus.tsx | 18 ++-- .../src/components/FileWriterSettings.tsx | 32 +++---- .../src/components/ImageFormatSettings.tsx | 32 +++---- frontend/src/components/ImagePusherStatus.tsx | 12 +-- frontend/src/components/IndexingSettings.tsx | 32 +++---- .../src/components/InstrumentMetadata.tsx | 32 ++----- .../src/components/MeasurementStatistics.tsx | 18 ++-- frontend/src/components/PixelMask.tsx | 91 +++++++++---------- frontend/src/components/ROI.tsx | 17 ++-- frontend/src/components/SettingsPanel.tsx | 81 +++++++++++++++++ frontend/src/components/ZeroMQPreview.tsx | 35 +++---- frontend/src/components/useUpload.ts | 41 +++++++++ 17 files changed, 311 insertions(+), 284 deletions(-) create mode 100644 frontend/src/components/SettingsPanel.tsx create mode 100644 frontend/src/components/useUpload.ts diff --git a/frontend/src/components/AzIntSettings.tsx b/frontend/src/components/AzIntSettings.tsx index af00ab69..83a29157 100644 --- a/frontend/src/components/AzIntSettings.tsx +++ b/frontend/src/components/AzIntSettings.tsx @@ -1,12 +1,13 @@ import {ChangeEvent, memo, useEffect, useState} from 'react'; -import Paper from '@mui/material/Paper'; import {FormControlLabel, Checkbox, Stack, Radio, RadioGroup, Typography} from "@mui/material"; import NumberTextField from "./NumberTextField"; import {azim_int_settings} from "../client"; +import {putConfigAzimIntMutation} from "../client/@tanstack/react-query.gen"; import _ from "lodash"; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; import FormControl from "@mui/material/FormControl"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: azim_int_settings @@ -29,6 +30,7 @@ function AzIntSettings({s: serverS}: MyProps) { const [lowQError, setLowQError] = useState(false); const [highQError, setHighQError] = useState(false); const [qSpacingError, setQSpacingError] = useState(false); + const { submit, pending, snackbar } = useUpload(putConfigAzimIntMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -39,15 +41,13 @@ function AzIntSettings({s: serverS}: MyProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [serverS]); - return -
- -
Azimuthal integration settings
+ const dirty = !_.isEqual(s, lastDownloadedS); + + return ( + submit(s)} + uploadDisabled={pending || (s.high_q_recipA <= s.low_q_recipA) || highQError || lowQError || qSpacingError} + snackbar={snackbar}> - - -
-
+ + ); } export default memo(AzIntSettings); diff --git a/frontend/src/components/DarkMaskSettings.tsx b/frontend/src/components/DarkMaskSettings.tsx index 9dac096c..5e59c1f4 100644 --- a/frontend/src/components/DarkMaskSettings.tsx +++ b/frontend/src/components/DarkMaskSettings.tsx @@ -1,10 +1,11 @@ import {memo, useEffect, useState} from 'react'; -import Paper from '@mui/material/Paper'; import {Checkbox, FormControlLabel, Stack} from '@mui/material'; import _ from 'lodash'; import NumberTextField from './NumberTextField'; -import ButtonWithSnackbar from './ButtonWithSnackbar'; import { dark_mask_settings } from '../client'; +import { putConfigDarkMaskMutation } from '../client/@tanstack/react-query.gen'; +import SettingsPanel from './SettingsPanel'; +import { useUpload } from './useUpload'; type MyProps = { s?: dark_mask_settings; @@ -28,6 +29,7 @@ function DarkMaskSettings({s: serverS}: MyProps) { const [numberOfFramesOld, setNumberOfFramesOld] = useState(1000); const [maxPixelCountErr, setMaxPixelCountErr] = useState(false); const [maxFramesWithSignalErr, setMaxFramesWithSignalErr] = useState(false); + const { submit, pending, snackbar } = useUpload(putConfigDarkMaskMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -51,17 +53,11 @@ function DarkMaskSettings({s: serverS}: MyProps) { maxPixelCountErr || maxFramesWithSignalErr; + const dirty = !_.isEqual(s, lastDownloadedS); + return ( - -
- - Dark data collection settings for mask + submit(s)} uploadDisabled={pending || hasError()} snackbar={snackbar}>
(DECTRIS detectors only)
- - -
-
+ ); } diff --git a/frontend/src/components/DataProcessingSettings.tsx b/frontend/src/components/DataProcessingSettings.tsx index 9a73519f..47a6183b 100644 --- a/frontend/src/components/DataProcessingSettings.tsx +++ b/frontend/src/components/DataProcessingSettings.tsx @@ -1,8 +1,8 @@ import {ChangeEvent, memo, useEffect, useState} from 'react'; -import Paper from '@mui/material/Paper'; import {Grid, Slider, Switch, Typography} from "@mui/material"; import {putConfigSpotFinding, spot_finding_settings} from "../client"; +import SettingsPanel from "./SettingsPanel"; import _ from "lodash"; type MyProps = { @@ -91,12 +91,12 @@ function DataProcessingSettings({s: serverS, update}: MyProps) { apply({...s, high_res_gap_Q_recipA: v}); }; - return + return ( + -
Spot finding parameters

Spot finding

@@ -183,7 +183,8 @@ function DataProcessingSettings({s: serverS, update}: MyProps) {
-
+ + ); } export default memo(DataProcessingSettings); diff --git a/frontend/src/components/DetectorSelection.tsx b/frontend/src/components/DetectorSelection.tsx index 9d7426fd..f3a771ab 100644 --- a/frontend/src/components/DetectorSelection.tsx +++ b/frontend/src/components/DetectorSelection.tsx @@ -1,13 +1,15 @@ import {memo, ReactNode, useEffect, useState} from 'react'; import Select, { SelectChangeEvent } from '@mui/material/Select'; -import {Stack, Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material"; +import {Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material"; import Paper from "@mui/material/Paper"; import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import FormControl from '@mui/material/FormControl'; import {detector_list, detector_list_element} from "../client"; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; +import {putConfigSelectDetectorMutation} from "../client/@tanstack/react-query.gen"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: detector_list @@ -39,6 +41,7 @@ const default_detector_element: detector_list_element = { function DetectorSelection({s}: MyProps) { const [choice, setChoice] = useState("0"); + const { submit, pending, snackbar } = useUpload(putConfigSelectDetectorMutation()); useEffect(() => { if (s !== undefined) @@ -105,13 +108,14 @@ function DetectorSelection({s}: MyProps) { return v; }; - return -
- - Detector selection + const dirty = s !== undefined && Number(choice) !== s.current_id; + + return ( + submit({ id: Number(choice) })} + uploadDisabled={pending || s === undefined} + snackbar={snackbar}> {detector_info()} @@ -128,17 +132,8 @@ function DetectorSelection({s}: MyProps) { {detector_options().map(d => ( {d.name} ))} - - -
-
+ + ); } export default memo(DetectorSelection); diff --git a/frontend/src/components/DetectorSettings.tsx b/frontend/src/components/DetectorSettings.tsx index 8685d1b0..66cb92e9 100644 --- a/frontend/src/components/DetectorSettings.tsx +++ b/frontend/src/components/DetectorSettings.tsx @@ -7,9 +7,13 @@ import InputLabel from "@mui/material/InputLabel"; import Select, {SelectChangeEvent} from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; import {detector_settings, detector_timing} from "../client"; +import {putConfigDetectorMutation} from "../client/@tanstack/react-query.gen"; import NumberTextField from "./NumberTextField"; import _ from "lodash"; import ButtonWithSnackbar from "./ButtonWithSnackbar"; +import Button from "@mui/material/Button"; +import {PanelTitle, UploadSnackbarView} from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: detector_settings @@ -46,6 +50,7 @@ function DetectorSettings({s: serverS}: MyProps) { const [eigerThresholdErr, setEigerThresholdErr] = useState(false); const [eigerThresholdKeVOld, setEigerThresholdKeVOld] = useState(6.0); const [downloadCounter, setDownloadCounter] = useState(0); + const { submit, pending, snackbar } = useUpload(putConfigDetectorMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -139,13 +144,15 @@ function DetectorSettings({s: serverS}: MyProps) { setS(prev => ({...prev, jungfrau_storage_cell_count: Number(event.target.value)})); }; + const dirty = !_.isEqual(s, lastDownloadedS); + return -
Detector settings +



-    +    +
} diff --git a/frontend/src/components/DetectorStatus.tsx b/frontend/src/components/DetectorStatus.tsx index 1c4e84e7..f01622d3 100644 --- a/frontend/src/components/DetectorStatus.tsx +++ b/frontend/src/components/DetectorStatus.tsx @@ -1,8 +1,9 @@ import {memo} from 'react'; import Paper from '@mui/material/Paper'; -import {Stack, Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material"; +import {Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material"; import {detector_status, detector_state, detector_power_state} from "../client"; +import SettingsPanel from "./SettingsPanel"; type MyProps = { s?: detector_status @@ -42,14 +43,8 @@ function DetectorStatus({s}: MyProps) { high_voltage_V: [] }; - return -
- - - Detector status + return ( + - -
-
+ + ); } export default memo(DetectorStatus); diff --git a/frontend/src/components/FileWriterSettings.tsx b/frontend/src/components/FileWriterSettings.tsx index de07c66a..ba1b5340 100644 --- a/frontend/src/components/FileWriterSettings.tsx +++ b/frontend/src/components/FileWriterSettings.tsx @@ -1,13 +1,14 @@ import {memo, useEffect, useState} from 'react'; -import Paper from '@mui/material/Paper'; -import {Checkbox, FormControlLabel, FormGroup, Select, Stack} from "@mui/material"; +import {Checkbox, FormControlLabel, FormGroup, Select} from "@mui/material"; import {file_writer_format, file_writer_settings} from "../client"; +import {putConfigFileWriterMutation} from "../client/@tanstack/react-query.gen"; import _ from "lodash"; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; import MenuItem from "@mui/material/MenuItem"; import InputLabel from "@mui/material/InputLabel"; import FormControl from "@mui/material/FormControl"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: file_writer_settings @@ -31,6 +32,7 @@ function stringToEnum(value: string): file_writer_format { function FileWriterSettings({s: serverS}: MyProps) { const [s, setS] = useState(default_file_writer_settings); const [lastDownloadedS, setLastDownloadedS] = useState(default_file_writer_settings); + const { submit, pending, snackbar } = useUpload(putConfigFileWriterMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -40,13 +42,11 @@ function FileWriterSettings({s: serverS}: MyProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [serverS]); - return -
- -
File Writer settings
+ const dirty = !_.isEqual(s, lastDownloadedS); + + return ( + submit(s)} uploadDisabled={pending} snackbar={snackbar}> No files saved - -
-
-
+ + ); } export default memo(FileWriterSettings); diff --git a/frontend/src/components/ImageFormatSettings.tsx b/frontend/src/components/ImageFormatSettings.tsx index 1c4eebef..9fa0753d 100644 --- a/frontend/src/components/ImageFormatSettings.tsx +++ b/frontend/src/components/ImageFormatSettings.tsx @@ -1,15 +1,16 @@ import {ChangeEvent, memo, useEffect, useState} from 'react'; import {Checkbox, FormControlLabel, FormGroup, List, ListItem, Stack, Tooltip} from "@mui/material"; -import Paper from "@mui/material/Paper"; import FormControl from "@mui/material/FormControl"; import InputLabel from "@mui/material/InputLabel"; import Select, {SelectChangeEvent} from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; import {image_format_settings} from "../client"; +import {putConfigImageFormatMutation} from "../client/@tanstack/react-query.gen"; import NumberTextField from "./NumberTextField"; import _ from "lodash"; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: image_format_settings @@ -59,6 +60,7 @@ function ImageFormatSettings({s: serverS}: MyProps) { const [jungfrauConversionFactorErr, setJungfrauConversionFactorErr] = useState(false); const [jungfrauConversionFactorOld, setJungfrauConversionFactorOld] = useState(12.4); const [downloadCounter, setDownloadCounter] = useState(0); + const { submit, pending, snackbar } = useUpload(putConfigImageFormatMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -103,13 +105,11 @@ function ImageFormatSettings({s: serverS}: MyProps) { setS(prev => ({...prev, signed_output: val})); }; - return -
- - Output image format settings + const dirty = !_.isEqual(s, lastDownloadedS); + + return ( + submit(s)} uploadDisabled={pending || jungfrauConversionFactorErr} snackbar={snackbar}> - - - -
-
+ + ); } export default memo(ImageFormatSettings); diff --git a/frontend/src/components/ImagePusherStatus.tsx b/frontend/src/components/ImagePusherStatus.tsx index 0a21e00a..71014d68 100644 --- a/frontend/src/components/ImagePusherStatus.tsx +++ b/frontend/src/components/ImagePusherStatus.tsx @@ -5,8 +5,9 @@ import {memo} from 'react'; import Paper from '@mui/material/Paper'; -import {Stack, Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material"; +import {Table, TableBody, TableCell, TableContainer, TableRow} from "@mui/material"; import {image_pusher_status, image_pusher_type} from "../client"; +import SettingsPanel from "./SettingsPanel"; type MyProps = { s?: image_pusher_status @@ -34,10 +35,7 @@ function renderPusherType(type?: image_pusher_type): string { function ImagePusherStatus({s}: MyProps) { return ( - -
- - Image pusher status + @@ -60,9 +58,7 @@ function ImagePusherStatus({s}: MyProps) {
-
-
-
+ ); } diff --git a/frontend/src/components/IndexingSettings.tsx b/frontend/src/components/IndexingSettings.tsx index 3e0c9b3b..cc8e45cb 100644 --- a/frontend/src/components/IndexingSettings.tsx +++ b/frontend/src/components/IndexingSettings.tsx @@ -1,15 +1,16 @@ import {memo, useEffect, useState} from 'react'; import {Stack, Typography, FormControlLabel, Checkbox} from "@mui/material"; -import Paper from "@mui/material/Paper"; import FormControl from "@mui/material/FormControl"; import InputLabel from "@mui/material/InputLabel"; import Select, {SelectChangeEvent} from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; import {broker_status, geom_refinement_algorithm, indexing_algorithm, indexing_settings} from "../client"; +import {putConfigIndexingMutation} from "../client/@tanstack/react-query.gen"; import NumberTextField from "./NumberTextField"; import _ from "lodash"; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: indexing_settings @@ -48,6 +49,7 @@ function IndexingSettings({s: serverS, status}: MyProps) { const [unitCellDistToleranceError, setUnitCellDistToleranceError] = useState(false); const [rotationIndexingMinAngularRangeDegError, setRotationIndexingMinAngularRangeDegError] = useState(false); const [rotationIndexingAngularStrideDegError, setRotationIndexingAngularStrideDegError] = useState(false); + const { submit, pending, snackbar } = useUpload(putConfigIndexingMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -86,13 +88,11 @@ function IndexingSettings({s: serverS, status}: MyProps) { || rotationIndexingAngularStrideDegError || rotationIndexingMinAngularRangeDegError; - return -
- - Indexing settings + const dirty = !_.isEqual(s, lastDownloadedS); + + return ( + submit(s)} uploadDisabled={pending || isError()} snackbar={snackbar}> Algorithm @@ -290,18 +290,8 @@ function IndexingSettings({s: serverS, status}: MyProps) { }} sx={{width: '80%'}}/> - - - -
-
+ + ); } export default memo(IndexingSettings); diff --git a/frontend/src/components/InstrumentMetadata.tsx b/frontend/src/components/InstrumentMetadata.tsx index 0d91a1fd..36c24607 100644 --- a/frontend/src/components/InstrumentMetadata.tsx +++ b/frontend/src/components/InstrumentMetadata.tsx @@ -4,13 +4,13 @@ import { Autocomplete, Checkbox, FormControlLabel, FormGroup, - Stack, TextField } from "@mui/material"; -import Paper from "@mui/material/Paper"; import {instrument_metadata} from "../client"; +import {putConfigInstrumentMutation} from "../client/@tanstack/react-query.gen"; import _ from "lodash"; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: instrument_metadata @@ -37,6 +37,7 @@ const source_types : string[] = [ function InstrumentMetadata({s: serverS}: MyProps) { const [s, setS] = useState(default_instrument_metadata); const [lastDownloadedS, setLastDownloadedS] = useState(default_instrument_metadata); + const { submit, pending, snackbar } = useUpload(putConfigInstrumentMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -46,16 +47,11 @@ function InstrumentMetadata({s: serverS}: MyProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [serverS]); - return - + const dirty = !_.isEqual(s, lastDownloadedS); - -
Source and instrument metadata
+ return ( + submit(s)} uploadDisabled={pending} snackbar={snackbar}> ) => { @@ -104,16 +100,8 @@ function InstrumentMetadata({s: serverS}: MyProps) { }}/> } label="Electron source"/> - -
-
-
+ + ); } export default memo(InstrumentMetadata); diff --git a/frontend/src/components/MeasurementStatistics.tsx b/frontend/src/components/MeasurementStatistics.tsx index 71bf5c3a..be41c2b7 100644 --- a/frontend/src/components/MeasurementStatistics.tsx +++ b/frontend/src/components/MeasurementStatistics.tsx @@ -1,20 +1,17 @@ import {memo} from 'react'; import Paper from '@mui/material/Paper'; -import {Grid, Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material"; +import {Table, TableBody, TableCell, TableContainer, TableRow,} from "@mui/material"; import {measurement_statistics} from "../client"; +import SettingsPanel from "./SettingsPanel"; type MyProps = { s: measurement_statistics, }; function MeasurementStatistics({s}: MyProps) { - return - - - -
Measurement statistics

- + return ( + @@ -75,11 +72,8 @@ function MeasurementStatistics({s}: MyProps) {
-
-
- - -
+ + ); } export default memo(MeasurementStatistics); diff --git a/frontend/src/components/PixelMask.tsx b/frontend/src/components/PixelMask.tsx index d10a6df8..ee7dcfb0 100644 --- a/frontend/src/components/PixelMask.tsx +++ b/frontend/src/components/PixelMask.tsx @@ -9,8 +9,10 @@ import { TableContainer, TableRow, TextField } from "@mui/material"; -import {putConfigUserMaskTiff, pixel_mask_statistics} from "../client"; -import Button from "@mui/material/Button"; +import {pixel_mask_statistics} from "../client"; +import {putConfigUserMaskTiffMutation} from "../client/@tanstack/react-query.gen"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: pixel_mask_statistics @@ -18,11 +20,7 @@ type MyProps = { function PixelMask({s}: MyProps) { const [file, setFile] = useState(); - - const handleUpload = () => { - if (file) - putConfigUserMaskTiff({ body: file }); - }; + const { submit, pending, snackbar } = useUpload(putConfigUserMaskTiffMutation()); const handleFileChange = (event: ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -30,47 +28,46 @@ function PixelMask({s}: MyProps) { } }; - return -
Pixel mask

- - - - - - - - User mask: - {s?.user_mask ?? "N/A"} - - - Error pixels: - {s?.wrong_gain ?? "N/A"} - - - Noisy pixels: - {s?.too_high_pedestal_rms ?? "N/A"} - - -
-
+ return ( + file && submit(file)} + uploadDisabled={!file || pending} + snackbar={snackbar}> + + + + + + + + User mask: + {s?.user_mask ?? "N/A"} + + + Error pixels: + {s?.wrong_gain ?? "N/A"} + + + Noisy pixels: + {s?.too_high_pedestal_rms ?? "N/A"} + + +
+
+
+ - - -
- Download pixel mask
- Download user pixel mask

-

- -

-
+ + + + ); } export default memo(PixelMask); diff --git a/frontend/src/components/ROI.tsx b/frontend/src/components/ROI.tsx index 882257a2..379ccad4 100644 --- a/frontend/src/components/ROI.tsx +++ b/frontend/src/components/ROI.tsx @@ -12,7 +12,9 @@ import Button from "@mui/material/Button"; import {DataGrid, GridActionsCellItem, GridColDef, GridRowModel} from "@mui/x-data-grid"; import DeleteIcon from '@mui/icons-material/DeleteOutlined'; import ErrorIcon from '@mui/icons-material/Error'; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; +import {putConfigRoiMutation} from "../client/@tanstack/react-query.gen"; +import {UploadSnackbarView} from "./SettingsPanel"; +import {useUpload} from "./useUpload"; import _ from "lodash"; type MyProps = { @@ -49,6 +51,7 @@ function ROI({s: serverS}: MyProps) { const [boxErr, setBoxErr] = useState(); const [circleErr, setCircleErr] = useState(); const [azimErr, setAzimErr] = useState(); + const { submit, pending, snackbar } = useUpload(putConfigRoiMutation()); useEffect(() => { if ((serverS !== undefined) && !_.isEqual(serverS, lastDownloadedS)) { @@ -386,19 +389,15 @@ function ROI({s: serverS}: MyProps) { disableElevation>Add Circle ROI      - +

+ } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx new file mode 100644 index 00000000..c9d93404 --- /dev/null +++ b/frontend/src/components/SettingsPanel.tsx @@ -0,0 +1,81 @@ +import { ReactNode } from "react"; +import { Alert, Box, Button, Paper, Snackbar, Stack, Tooltip, Typography } from "@mui/material"; +import { SxProps, Theme } from "@mui/material/styles"; +import { TypographyProps } from "@mui/material/Typography"; +import { UploadSnackbar } from "./useUpload"; + +/** The upload-result snackbar, usable on its own by panels that don't use SettingsPanel. */ +export function UploadSnackbarView({ snackbar }: { snackbar?: UploadSnackbar }) { + if (!snackbar) return null; + return ( + + + {snackbar.success ? "Ok!" : snackbar.message} + + + ); +} + +/** A title with an optional "unsaved changes" marker. */ +export function PanelTitle({ title, variant = "h6", dirty }: { title: string; variant?: TypographyProps["variant"]; dirty?: boolean }) { + return ( + + {title} + {dirty && ( + + + + )} + + ); +} + +type SettingsPanelProps = { + title?: string; + titleVariant?: TypographyProps["variant"]; + dirty?: boolean; + onUpload?: () => void; + uploadText?: string; + uploadDisabled?: boolean; + snackbar?: UploadSnackbar; + sx?: SxProps; + spacing?: number; + children: ReactNode; +}; + +/** + * Consistent shell for the expert configuration panels: a padded Paper, a title + * (with an optional "unsaved changes" marker), the panel widgets, an optional + * Upload button, and the upload result snackbar. + */ +function SettingsPanel({ + title, + titleVariant = "h6", + dirty, + onUpload, + uploadText = "Upload", + uploadDisabled, + snackbar, + sx, + spacing = 3, + children, +}: SettingsPanelProps) { + return ( + + + {title !== undefined && } + {children} + {onUpload && ( + + )} + + + + ); +} + +export default SettingsPanel; diff --git a/frontend/src/components/ZeroMQPreview.tsx b/frontend/src/components/ZeroMQPreview.tsx index c5cc1642..bc9dfb5c 100644 --- a/frontend/src/components/ZeroMQPreview.tsx +++ b/frontend/src/components/ZeroMQPreview.tsx @@ -1,12 +1,13 @@ import {memo, useEffect, useState} from 'react'; -import Paper from '@mui/material/Paper'; -import {FormControlLabel, Radio, RadioGroup, Stack} from "@mui/material"; +import {FormControlLabel, Radio, RadioGroup} from "@mui/material"; import NumberTextField from "./NumberTextField"; import {zeromq_preview_settings} from "../client"; +import {putConfigZeromqPreviewMutation} from "../client/@tanstack/react-query.gen"; import _ from "lodash"; import FormControl from "@mui/material/FormControl"; -import ButtonWithSnackbar from "./ButtonWithSnackbar"; +import SettingsPanel from "./SettingsPanel"; +import {useUpload} from "./useUpload"; type MyProps = { s?: zeromq_preview_settings @@ -25,6 +26,7 @@ function ZeroMQPreview({s: serverS}: MyProps) { const [downloadCounter, setDownloadCounter] = useState(0); const [freqChoice, setFreqChoice] = useState("custom"); const [periodMs, setPeriodMs] = useState(default_zeromq_preview_settings.period_ms); + const { submit, pending, snackbar } = useUpload(putConfigZeromqPreviewMutation()); const update_freq = (choice: string) => { switch (choice) { @@ -69,13 +71,13 @@ function ZeroMQPreview({s: serverS}: MyProps) { const empty = () : boolean => (s.socket_address === undefined) || (s.socket_address === ""); - return -
- -
ZeroMQ preview settings
+ const dirty = !_.isEqual(s, lastDownloadedS); + + return ( + submit(s)} + uploadDisabled={pending || ((freqChoice == "custom") && periodErr)} + snackbar={snackbar}>
{empty() ? "No preview available" : `ZeroMQ socket: ${s.socket_address}`}
@@ -103,17 +105,8 @@ function ZeroMQPreview({s: serverS}: MyProps) { }} disabled={freqChoice != "custom"} fullWidth/> - -
-
-
+ + ); } export default memo(ZeroMQPreview); diff --git a/frontend/src/components/useUpload.ts b/frontend/src/components/useUpload.ts new file mode 100644 index 00000000..ce4c1877 --- /dev/null +++ b/frontend/src/components/useUpload.ts @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; + +/** Pull a human-readable message out of a hey-api error (400 = string, 500 = error_message). */ +export function errorMessage(error: unknown): string { + if (typeof error === "string") return error; + if (error && typeof error === "object" && "msg" in error) + return String((error as { msg?: unknown }).msg ?? "Unknown error"); + return "Upload failed"; +} + +export type UploadSnackbar = { + open: boolean; + success: boolean; + message: string; + onClose: () => void; +}; + +/** + * Wraps a generated hey-api mutation (e.g. putConfigFileWriterMutation()) and + * surfaces the result as a snackbar. submit(body) sends the typed body; on + * failure the server's error message is shown. + */ +export function useUpload( + mutationOptions: UseMutationOptions, +) { + const mutation = useMutation(mutationOptions); + const [open, setOpen] = useState(false); + + const submit = (body: TVars["body"]) => + mutation.mutate({ body } as TVars, { onSettled: () => setOpen(true) }); + + const snackbar: UploadSnackbar = { + open, + success: mutation.isSuccess, + message: mutation.error ? errorMessage(mutation.error) : "", + onClose: () => setOpen(false), + }; + + return { submit, pending: mutation.isPending, snackbar }; +}