276 lines
6.8 KiB
JavaScript
276 lines
6.8 KiB
JavaScript
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 (
|
|
<Typography
|
|
variant="overline"
|
|
color="text.secondary"
|
|
sx={{ display: 'block', mb: 1, pr: onSave ? 9 : 0 }}
|
|
>
|
|
{title}
|
|
</Typography>
|
|
);
|
|
}
|
|
|
|
renderActions() {
|
|
const { onSave } = this.props;
|
|
if (!onSave) return null;
|
|
|
|
const { editing, saving } = this.state;
|
|
const iconSx = tileActionIconSx;
|
|
|
|
if (!editing) {
|
|
return (
|
|
<>
|
|
<IconButton
|
|
disabled
|
|
aria-hidden
|
|
tabIndex={-1}
|
|
sx={{
|
|
...tileActionSx,
|
|
right: 48,
|
|
visibility: 'hidden',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
<SaveOutlinedIcon sx={iconSx} />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={this.handleStartEdit}
|
|
aria-label="Bearbeiten"
|
|
sx={{ ...tileActionSx, right: 4 }}
|
|
>
|
|
<EditOutlinedIcon sx={iconSx} />
|
|
</IconButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<IconButton
|
|
onClick={this.handleSave}
|
|
disabled={!this.canSave()}
|
|
aria-label="Speichern"
|
|
color={this.canSave() ? 'primary' : 'default'}
|
|
sx={{ ...tileActionSx, right: 48 }}
|
|
>
|
|
{saving ? (
|
|
<CircularProgress size={22} color="inherit" />
|
|
) : (
|
|
<SaveOutlinedIcon sx={iconSx} />
|
|
)}
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={this.handleCancel}
|
|
disabled={saving}
|
|
aria-label="Abbrechen"
|
|
sx={{ ...tileActionSx, right: 4 }}
|
|
>
|
|
<CloseOutlinedIcon sx={iconSx} />
|
|
</IconButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Paper
|
|
elevation={4}
|
|
sx={panelTilePaperSx({
|
|
wide,
|
|
sx,
|
|
minHeight,
|
|
editing,
|
|
pending,
|
|
tileKey,
|
|
theme,
|
|
})}
|
|
>
|
|
{this.renderActions()}
|
|
{this.renderOverlayNode()}
|
|
{this.renderTitle()}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
pr: actionPadding,
|
|
display: 'flex',
|
|
alignItems: contentAlignItems,
|
|
}}
|
|
onKeyDown={editing ? this.handleEditKeyDown : undefined}
|
|
>
|
|
{this.renderBody()}
|
|
</Box>
|
|
{error ? (
|
|
<Alert severity="error" sx={{ mt: 1, py: 0 }}>
|
|
{error}
|
|
</Alert>
|
|
) : null}
|
|
</Paper>
|
|
);
|
|
}
|
|
}
|
|
|
|
export const EditablePanelTile = injectMuiTheme(EditablePanelTileBase);
|