import { Component, createRef } from 'react'; import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; import { injectMuiTheme } from './injectMuiTheme'; import { enqueueSnackbar } from 'notistack'; import { backendErrorFrom } from '../../utils/backendError'; import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout'; function valuesEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); } /** * Panel tile with centralized inline-edit chrome. * Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon). */ class EditablePanelTileBase extends Component { constructor(props) { super(props); this.inputRef = createRef(); this.state = { editing: false, draft: null, saving: false, error: null, }; } componentDidUpdate(_prevProps, prevState) { if (!prevState.editing && this.state.editing) { this.inputRef.current?.focus(); } } getSavedValue() { return this.props.value; } isDirty() { const { draft, editing } = this.state; if (!editing || draft == null) return false; if (this.props.isDirty) { return this.props.isDirty(draft, this.getSavedValue()); } return !valuesEqual(draft, this.getSavedValue()); } canSave() { const { draft, saving } = this.state; if (saving || !this.isDirty() || draft == null) return false; if (this.props.canSave) { return this.props.canSave(draft, this.getSavedValue()); } return true; } handleStartEdit = (event) => { event.stopPropagation(); this.setState({ editing: true, draft: structuredClone(this.getSavedValue()), error: null, }); }; handleCancel = (event) => { event?.stopPropagation(); this.setState({ editing: false, draft: null, error: null, }); }; handleDraftChange = (nextDraft) => { this.setState({ draft: nextDraft, error: null }); }; handleEditKeyDown = (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.handleCancel(); return; } if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); event.stopPropagation(); if (this.canSave()) { void this.handleSave(event); } } }; handleSave = async (event) => { event?.stopPropagation(); if (!this.canSave()) return; const { onSave } = this.props; const { draft } = this.state; this.setState({ saving: true, error: null }); try { await onSave(draft); this.setState({ editing: false, draft: null, saving: false, error: null }); enqueueSnackbar('Gespeichert.', { variant: 'success' }); } catch (err) { const normalized = backendErrorFrom(err); this.setState({ saving: false, error: normalized.message }); enqueueSnackbar(normalized.message, { variant: 'error' }); } }; renderTitle() { const { title, onSave } = this.props; if (!title) return null; return ( {title} ); } renderActions() { const { onSave } = this.props; if (!onSave) return null; const { editing, saving } = this.state; const iconSx = tileActionIconSx; if (!editing) { return ( <> ); } return ( <> {saving ? ( ) : ( )} ); } render() { const { onSave, theme, wide = 1, sx, minHeight = 120, tileKey, renderDisplay, renderEdit } = this.props; const { editing, draft, error } = this.state; const savedValue = this.getSavedValue(); const pending = this.isDirty(); const actionPadding = onSave ? TILE_ACTION_PADDING : 0; return ( {this.renderActions()} {this.renderTitle()} {editing && draft != null ? renderEdit(draft, this.handleDraftChange, this.inputRef) : renderDisplay(savedValue)} {error ? ( {error} ) : null} ); } } export const EditablePanelTile = injectMuiTheme(EditablePanelTileBase);