import { Component, cloneElement, createRef, isValidElement } 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 { notifyError } from '../../utils/notify'; 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). * * Content is element props (not render callbacks): * display — React node shown when not editing * edit — element cloned with { draft, onChange, inputRef } while editing * overlay — optional element cloned with { editing, draft, onChange } */ 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; try { await onSave(draft); this.setState({ editing: false, draft: null, saving: false, error: null }); } catch (err) { const normalized = notifyError(err); this.setState({ error: normalized.message }); } }; 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 ? ( ) : ( )} ); } renderBody() { const { display, edit } = this.props; const { editing, draft } = this.state; if (editing && draft != null && isValidElement(edit)) { return cloneElement(edit, { draft, onChange: this.handleDraftChange, inputRef: this.inputRef, }); } return display; } renderOverlayNode() { const { overlay } = this.props; const { editing, draft } = this.state; if (!isValidElement(overlay)) return null; return cloneElement(overlay, { editing, draft, onChange: this.handleDraftChange, }); } render() { const { onSave, theme, wide = 1, sx, minHeight = 120, tileKey, contentAlignItems = 'center', } = this.props; const { editing, error } = this.state; const pending = this.isDirty(); const actionPadding = onSave ? TILE_ACTION_PADDING : 0; return ( {this.renderActions()} {this.renderOverlayNode()} {this.renderTitle()} {this.renderBody()} {error ? ( {error} ) : null} ); } } export const EditablePanelTile = injectMuiTheme(EditablePanelTileBase);